Recently, I shared why I like designing in the browser. But I didn’t get there overnight: The process was gradual, building up over time as I bumped against the edges of dedicated design software and static deliverables. And I had to start somewhere.
If you’re a designer who’d like to give in-browser mockups a try in your own work, this article could be your starting point.
My intent here isn’t to evangelize this way of doing things: I’ve met too many amazing designers with too many different processes to believe one size fits all. And this isn’t for developers interested in design: Stephanie Stimac already wrote a whole book for you.
These are simply a few tips I’ve seen resonate with designers on client teams who have a spark of interest or curiosity, but aren’t sure where to begin.
There’s a good chance you already augment your design artifacts with external documents, slide decks, animations or specifications. You might reach for xScope or a color picker to streamline certain tasks. Web standards can be just another gadget in your design utility belt.
Say you’re crafting a responsive grid system, and you’d like to whip up a demo so developers won’t need to improvise between breakpoints:
Example of a responsive 12-column grid visualized using CSS (with thin guides for typographic spacing), resizing from 320 to 1440 pixels wide. (Live example on CodePen)Perhaps you’re designing an element with unique considerations for interaction states, and you’d like to know you’ve conveyed the intended experience:
CodePen Embed FallbackOr maybe you’d like to share an enhanced color palette, using OKLCH to give colors more oomph on high-gamut displays:
CodePen Embed FallbackWherever you sense friction in the features of your software or the boundaries of a fixed frame, wherever there’s a design question typically left unanswered until implementation, there’s an opportunity to augment your usual process with a pinch of web standards.
Learn a Few BasicsYou don’t need to be a seasoned web developer to mock-up HTML and CSS for the browser. But knowing a few fundamentals can help chart the clearest course from idea to reality:
If you’ve never made a web page before, HTML for People is a free ebook by Blake Watson written for those with “no prior coding experience of any kind.” Very approachable!
Don’t feel pressure to learn more than you need up front. Your goal is to conceptualize a particular design idea as styled HTML, not to ship pristine, production-ready code. (Though with enough practice, you might accomplish that by accident from time to time!)
Don’t worry: The most important thing is what displays in the browser. How you get there is completely up to you!
If you’re happy with TextEdit or Notepad, that’s perfectly fine. If you’re itching for a bit more from your editor, I’ve seen these suggestions resonate with a few designers:
What about AI?Most code editors support plugins for AI-powered suggestions or code generation. Some like Cursor, Windsurf and Zed were built with these features in mind.
Ethical considerations aside, if you choose to use these tools, you must take care to scrutinize and understand the output. As a designer loosely mocking up self-contained concepts for modern, capable browsers, your HTML and CSS needs should be simple and straightforward compared to the decades of outdated or complicated code AI models were trained on. You can easily prompt yourself into a corner of frustrating complexity, curtailing your own design voice only to recreate what came before.
Let’s say you come across an interesting web experience, and you’d like to know how it was done. First, open “dev tools” (as they’re often called) in your browser of choice:
From there, you can inspect any page element that catches your eye, revealing its markup and styles, even toggling or changing styles on the fly:
Using dev tools to inspect and tweak the details of my site’s project listing.This ability to peek behind the curtain is an invaluable learning tool for newcomers and experts alike. (It’s great for troubleshooting your own designs, too!)
Need inspiration?If the web experiences you come across all look the same, consider following more people who design in the browser! We’re a bit hard to find since we can’t agree on a title (Web Designer? Designer-Developer? Design Engineer? Creative Developer?), but we’re out there!
I’ll assume if you’ve read this far you already follow me and Cloud Four. But here are some more folks who use web tech to craft inventive designs, courtesy of my own feeds and a few suggestions from social media:
About this listI tried to focus on individuals with plenty of self-hosted, freely available, prominent or recent hybrid design content, whose work I felt would appeal to designers with less technical experience. Sincere apologies to anyone I overlooked or forgot to mention. I may update this list from time to time.
If you’re looking for more of a Dribbble equivalent for interactive works, CodePen has a “Trending” section that’ll blow your mind.
It is the nature of the web to be flexible, and it should be our role as designers and developers to embrace this flexibility, and produce pages which, by being flexible, are accessible to all.
In The Web’s Grain, Frank Chimero illustrates why web and interaction design are so unique:
It is an edgeless surface of unknown proportions, comprised of small, individual, and variable elements from multiple vantages assembled into a readable whole that documents a moment.
If you approach designing in HTML and CSS with the exact same mindset as in Figma or Sketch, you’ll come away pretty frustrated. But if you can relax some pixel perfectionism, embrace the web’s inherent flexibility and edgeless-ness, and allow its quirks and constraints to guide your hand… that’s where you’ll make your most meaningful discoveries.
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
We don’t use utility classes as often as we used to, but they still come in handy on occasion.
One challenge when styling utilities is to provide more value than an inline style without sacrificing versatility.
Consider this utility for setting a border size:
.border-thick { border-width: var(--size-border-thick);}
That works great for elements that already have a border style:
But otherwise, it has no effect. If you wanted to add a border to an otherwise borderless element to offset it from its background, you’re fresh out of luck:
Fair enough, let’s add a border-style as well:
.border-thick { border-style: solid; border-width: var(--size-border-thick);}
That works, but the border color inherits the text color by default, which feels a little too prominent:
Maybe we should set a default color as well?
.border-thick { border-color: var(--color-border-subtle); border-style: solid; border-width: var(--size-border-thick);}
But now we’ve traded one problem for another. Our utility looks great on borderless elements, but look what it’s done to our poor, hapless button:
To make our utility class useful on its own without overreaching, we need to clarify which CSS rules should courageously hold the line (in this case, border-width), and which should roll over and show their belly at the first sign of contention.
Legacy SolutionsHistorically, this has been a tough problem to solve.
If you’ve dug into the code of many popular open source frameworks, you might mistake the !default flag for a native CSS feature. But it’s an invention of Sass, the iconic CSS preprocessor. Useful for authoring, but it can’t resolve conflicts in the browser… we must look elsewhere.
We could use Harry Roberts’ class-chaining technique to increase the specificity of certain styles:
.button.button { border: var(--size-border-thin) solid var(--color-border-button);}.border-thick { border-color: var(--color-border-subtle);}.border-thick.border-thick { border-style: solid; border-width: var(--size-border-thick);}
That works, and it’s useful in a pinch, but it demands a lot of repetition. It would take a lot of diligence to maintain consistent selector chain lengths across a whole project.
We could include our utilities near the beginning of our CSS, and add the !important flag to styles we’d like to act as overrides:
/* utilities first */.border-thick { border-color: var(--color-border-subtle); border-style: solid; border-width: var(--size-border-thick) !important;}/* components later */.button { border: var(--size-border-thin) solid var(--color-border-button);}
That’s easier to read and write, but it may struggle against the needs of critical CSS or other !important styles.
Reviewing these techniques one after another, I see why many CSS frameworks chose not to bother. It’s a bummer requiring multiple classes for useful results, but that was the simpler option.
Emphasis on was. We’ve got some pretty sweet alternatives today!
Modern MethodsWe can move our defaults (the styles we want to chicken out ASAP) to a :where selector. This applies the same rules but with zero specificity:
:where(.border-thick) { border-color: var(--color-border-subtle); border-style: solid;}.border-thick { border-width: var(--size-border-thick);}
Now border-width is applied regardless, but border-color and border-style turn tail at the first sign of trouble:
Hooray! 🎉
Alternatively, we could use a cascade layer. Cascade layers always have lower precedence than un-layered CSS:
@layer { .border-thick { border-color: var(--color-border-subtle); border-style: solid; }}.border-thick { border-width: var(--size-border-thick);}
But if you plan to do this sort of thing across a whole project, I’d recommend naming your layers ahead of time. You can specify their order of precedence early on in your CSS:
@layer base, component, utility;
Now, any styles we add to our base layer will defer to our component layer, which will defer to our utility layer, no matter where those styles are written:
/* components/button.css */@layer component { .button { border: var(--size-border-button) solid var(--color-border-button); /* other button styles */ }}/* utilities/border.css */@layer base { .border-thick { border-color: var(--color-border-subtle); border-style: solid; }}@layer utility { .border-thick { border-width: var(--size-border-thick); }}
Cascade layers really come in handy for managing style precedence, and this example only scratches the surface. (For a deeper dive, check out Stephanie Eckles’ wonderful introduction for Smashing Magazine.)
Either technique is very well supported. :where achieved baseline support in 2021, cascade layers did the same the following year.
Bonus Tip: More UtilitiesIf we plan to include more than one border-* utility class, we can expand the first selector to set more defaults and match more classes:
:where([class^='border-'], [class*=' border-']) { border-color: var(--color-border-base); border-style: solid; border-width: 0;}/* or */@layer base { [class^='border-'], [class*=' border-'] { border-color: var(--color-border-subtle); border-style: solid; border-width: 0; }}
This attribute selector matches any class beginning with border- (border-thin, border-dots, border-purple, etc.). No need to maintain a big ol’ selector list by hand!
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
Very early in my career, I found myself on a design team tasked with improving some legacy security software. At one point, we were evaluating a setup screen that included a progress bar. I asked a member of the engineering team, “How is progress calculated?”
“Oh, it’s complete nonsense,” they replied with a chuckle. Then they explained…
The progress bar filled every few seconds, regardless of actual progress. The first cycle filled by a set amount, then a fraction of that, then a fraction of that (etc.), so the bar would never completely fill on its own. Once the operation actually finished, progress jumped from wherever it happened to be to 100%.
Today, I can spot this sort of trickery a mile away. But at the time, I was young, naive, and a little appalled by this explanation. “You’re lying to the user?” I thought.
Thankfully, I chose to ask a more productive question aloud…
“Why?”I was told this particular operation was complex, with a lot of different factors that could vary wildly from user to user. This made it challenging to predict ahead of time, and resource-intensive to estimate dynamically.
If they included no progress indicator, or even a repeating animation like a spinner or indeterminate “barber’s pole” bar, users might worry some aspect of the process had stalled or crashed. They might cancel the operation unnecessarily.
It was decided that a fictional progress bar was a necessary evil. By demonstrating some amount of forward momentum, users felt more confident that the operation was continuing as intended, with fewer abandoning the setup process.
Doing BetterI had mixed feelings about that explanation.
On one hand, the artificial progress indicator solved some of the challenges the team described. It exposed me to the concept of perceived performance and reminded me to always question assumptions.
On the other hand, making stuff up felt like the lowest possible bar to reach.
Thankfully, I’ve since had plenty of opportunities to design more forthcoming progress indicators within similar constraints. The process is always the same:
Sometimes, you’ll discover more accurate estimation is possible, and worth a small amount of performance loss to provide overall clarity to the end user:
Other times, an ETA just isn’t feasible or appropriate. If you know what step of the operation you’re on and how many remain, you can visualize total progress without relying on time:
If the total number of steps isn’t knowable, counting the number of completed steps so far reassures users that progress is happening:
Even when all else fails, be honest. And if you can, make it fun:
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
It can be surprising for new clients to see just how much of our design process happens in HTML, CSS and (light) JavaScript. While we do plenty of ideation exercises, sketching, wireframes, mockups and more, we like to get our hands dirty in the browser as soon as we can.
There are business and process benefits to this approach, which we’ve written about before. In this article, I hope to answer a much smaller question:
What do I, a designer of 20+ years with many static mockups to his name, personally enjoy about designing in-browser with web standards in 2025?
For balance, I’ll include a few reasons why I enjoy dedicated design software, too.
Sound good? Let’s dive in!
True to LifeMost design tools only approximate how the end result will look and feel. Will typography render as intended? Is that animation smooth or kinda janky? Does that toolbar feel weird when the virtual keyboard is visible? Is this idea even feasible?
When I’m already working in HTML and CSS, there’s no guessing. I immediately experience the strengths and weaknesses of the medium firsthand, and I can adapt to that reality in the moment instead of having to compromise much later in the process.
New Features, No WaitingMany standards, especially in the last decade, don’t just streamline implementation: They open up whole new creative possibilities! CSS grid and subgrid, high-gamut color, container queries, scroll-driven animations, view transitions, color schemes and more!
Some of these ideas make it into design tools, but the wait can be long… understandably so, making interfaces for this stuff is hard! By the time Figma introduced their flexbox equivalent, the more powerful CSS Grid was already years into baseline availability.
It’s so much fun designing experiences with newer features as soon as our audience can benefit from them.
Markup Makes a Great StartThey say form should follow function, content should precede design, and there’s nothing more intimidating than a blank canvas. All reasons I love HTML as a starting point!
No matter what font family or color palette we’ve settled on, even if we’re waiting for finalized copy or feature requirements, there are usually a few basics I know a page will need: Headings, copy, navigation, form elements, etc.
It’s amazing how quickly I can stub out the majority of an interface’s building blocks with a small amount of basic HTML. Plus, that foundation can be surprisingly functional: I get links, accordions and various input types for free.
Fluid by DefaultWhether you grew up with Silly Putty, Stretch Armstrong, Gak or a YouTube-fueled fascination with slime, you know that squashing and stretching stuff is fun. That’s how web pages work by default: Most HTML elements want to Elasti-Girl their way through any viewport size.
By comparison, design tools tend to assume static canvases of a fixed size. Some allow fluid-layout areas, or will approximate scrolling in a prototype mode. But you’ll probably end up maintaining separate mockups for a fraction of possible breakpoints.
So having a single design that’s nice and liquid by default? Perfect, no notes.
Deeper Than PixelsA good designer doesn’t pull pixel values out of thin air. These sizes result from a thought process, informed by an element’s importance and its relationship to everything around it. You didn’t size that heading at 36px because it’s your favorite number: You wanted it twice the size of your 18px body copy for adequate contrast.
CSS encourages you to express dimensions based on that intent, leaving most of the math to the browser:
2em.em for rem.vw units. Container width? Give cqi a whirl.calc lets you add, subtract, multiply and (usually) divide these values however you like.clamp lets you define that range.And that’s just typography! Once you’re used to sizing things based on their relationship to the rest of the UI, pixels start to feel pretty limited by comparison.
The CascadeEven seemingly minor design feedback can have a ripple effect. Unless you’re super diligent about maintaining a library of flexible symbols as you work, simply updating a button’s color could mean slogging your way through way too many mockups.
But in the browser, I’m not the one making those changes. That’s the browser’s job, based on instructions I write in the form of CSS. And I can apply those changes to as many elements as I want:
button { background-color: rebeccapurple; color: #fff;}
Or as few as I want:
@container (inline-size >= 30em) { .intro > #cta:first-child { background-color: rebeccapurple; color: #fff; }}
I type a few lines, hit ⌘ + S, and go take my dog for a walk.
Portable, Shareable, OpenThere are countless ways to share browser-based mockups. Upload the files to a web server. Use a service like CodePen, Neocities or Val Town. If you know your way around a code repository, hook it up to Netlify or Cloudflare Pages or whatever. Drag a folder to a 3½-inch floppy disk, hide the disk in a hollowed-out book, leave the book at an agreed upon location. As long as those files make it to a web browser, you can view them, no license or subscription required.
And can we talk about the awesomeness that is dev tools? In any modern browser, developers (or curious nerds of any discipline) can inspect every size, color and property of every single element of the page without any additional effort from the designer. Super-powered design specs, absolutely free.
I Like Dedicated Design Tools, Too!In the same way that I don’t limit myself to a single pen when drawing, I don’t expect a code editor to reasonably solve every design problem.
Most often, I rely on design apps for preproduction and asset creation. Flow charts, loose sketches, element collages, illustrations and iconography, preparation of images and video. These artifacts tend to benefit from free-floating layers and fixed dimensions. They may be theoretically possible to pull off in code (handwriting SVG, for example), but that can be a real slog.
And it’s hard to argue that design software isn’t more approachable in a project’s early days. I find HTML and CSS easier to grok than most Photoshop menus, but it’s a shorter walk from blank canvas to pretty pixels in a tool purpose-built for the task.
Usually, I’ll bounce back and forth. I’ll mock something up in the browser, then paste a screenshot in my image editor to quickly rough out a different idea. Or I’ll whip up an icon in Illustrator, then paste the SVG export back in my prototype.
Ultimately, any one design artifact is only as useful as what it contributes to the end result. No matter how much I enjoy a particular tool or workflow, if there’s too much friction or busy work for too little value, I’ll drop it like a hot potato.
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
Two of our clients with websites on different platforms have encountered the same problem: their hosting provider says they automatically converts images to WebP, but we never see any WebP images. In both cases, this is due to a Cloudflare Polish configuration setting.
The two hosts in question are BigCommerce and WP Engine. In BigCommerce’s defense, they don’t publicly promote WebP support, but support threads from knowledgeable partners made it seem like automatic WebP support was expected. On the other hand, WP Engine has an entire knowledge base article about WebP wherein they say:
If you are using WP Engine’s Global Edge Security or advanced network, Cloudflare Polish is enabled by default. This means your site will use WebP images automatically, without any additional configuration steps.
Unfortunately, this is untrue. Or rather, it is technically true, but it is extremely unlikely Cloudflare Polish will ever automatically convert an image to WebP because of the way WP Engine has Polish configured.
What is Cloudflare Polish?Cloudflare Polish advertises itself as a “one-click image optimization product.” It does some nice things like removing meta-data from images, setting caches, and converting images into WebP format.
Like most “one-click” solutions, it’s missing features that I think are necessary for a responsive images solution (e.g., resizing images based on URL parameters and AVIF support), but I can understand the appeal of Polish for many organizations and especially for hosting providers who can speed up their client sites without asking the website owners to update their code.
Lossless setting means no WebPWhen Cloudflare Polish encounters an image, it evaluates whether converting the image to WebP format will result in a smaller file size. If it does, it converts the image on-the-fly and the end user is none-the-wiser. You can only tell the image has been converted by looking at the image type in the browser’s developer tools.
So why isn’t Cloudflare Polish automatically converting images to WebP for BigCommerce and WP Engine customers? It is because they have Polish’s lossless compression option turned on.
Most image formats trade image quality for smaller file sizes. When they do this, it is called a lossy compression. In JPEG, you can set the quality you want. The lower the quality setting, the smaller the file will be.
While lossy image formats are most common—particularly for photographs—there are formats like TIFF which are lossless image formats. PNG and WebP support both lossy and lossless formats. PNG24 and PNG32 are both lossless. Whereas PNG8 was designed to replace GIFs and thus is lossy. WebP doesn’t have different names for the lossy versus lossless formats, but it does support them.
By now you may have figured out where the problem lies. If Polish’s lossless option is turned on, it is unlikely that images uploaded in any lossless format (e.g., JPEG) will ever be smaller when converted to lossless WebP. In fact, Cloudflare’s documentation says:
The Lossless option prevents conversion of JPEG to WebP, because this is always a lossy operation.
I contacted BigCommerce and WP Engine to find out if the lossless option was turned on for their implementations. BigCommerce confirmed it in a support ticket. WP Engine support pointed me to their documentation for their advanced network which says it includes “Cloudflare Polish lossless image compression.”
BigCommerce and WP Engine should turn off losslessThe main promise of Cloudflare Polish is that it is a “one-click image optimization product that automatically optimizes images.” But if the lossless setting is on, the amount of optimization that Polish can do is minimal. Removing meta data and setting caches won’t make a big difference in image file size. Converting an image to lossy WebP can make a big difference.
BigCommerce and WP Engine customers would benefit greatly from turning off Cloudflare Polish’s lossless setting.
Or they need to stop saying they support WebPWhile flipping the switch to turn off the lossless option is at most a five-minute task, I can understand why BigCommerce and WP Engine might be reluctant to do so. That one switch will affect every customer. I believe it will be a positive change, but any change that affects all customers will inevitably upset some.
So if they are unable to turn off the lossless option, then they need to stop claiming to support WebP. In particular, WP Engine’s knowledge base article on WebP is at best misleading, and at worst, completely wrong given Cloudflare’s documentation on how Polish behaves when lossless is turned on.
Does this affect your hosting provider as well?If I had only encountered this on one hosting provider, I wouldn’t have written about it. But now that I’ve seen two providers with this problem, I wondered how many others might be impacted.
It is easy to imagine a hosting provider signing up for Cloudflare, thinking they were getting WebP support, and never realizing that their settings are preventing images from getting converted.
Because Cloudflare is supposed to only convert images if the file size is smaller, the problem can be easy to miss. In fact, when I contacted support at both companies, the initial response I received was that images weren’t getting converted to WebP because Polish found that the file sizes weren’t smaller.
If you’re on another hosting provider and they use Cloudflare Polish, you may want to make sure that your images are actually getting converted to WebP.
What can Cloudflare do?Perhaps the implementors at BigCommerce and WP Engine both made mistakes and these are isolated cases. I can’t tell. But I do wonder if Cloudflare might share part of the blame here.
At minimum, Cloudflare hosting clients should know that if have lossless turned on, that they don’t support WebP and they shouldn’t claim that support.
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
A few years ago, browsers implemented a standard way of lazy loading images. The standard was a huge boon because it was straightforward to use and less code than JavaScript solutions. Unfortunately, when it was released, it didn’t support a couple of common use cases. Now it does.
To lazy load an image, all you have to do is add loading="lazy" to an image element and the browser knows not to load it until the image is about to be visible in the viewport.
When it was released, loading="lazy" worked great for images further down on the page that a user might scroll to, but it didn’t support hidden images in the initial viewport. Many websites will have images in their menus or in a carousel high on the page that you likely don’t want to download ahead of images that are visible. You probably want those images to lazy load.
Because of this limitation, we often recommended implementing a lazy loading JavaScript library for images in menus or in carousels while using loading="lazy" for all of the images further down on the page.
A few days ago, I learned browsers upgraded the lazy loading standard to support these hidden image use cases. If you have images in menus or carousels or some other interface that is technically above the fold, but not visible, all you need to do is add loading="lazy" and the browser will defer downloading the image. You no longer need a JavaScript solution for those use cases.
Apparently, the change happened a couple of years ago, and I missed the good news. So I thought I’d share it in cases others missed the change as well.
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
Containers! Where would we be without them? All our menu items and body copy and button text, all loose and adrift in our viewports… un-contained! Chaos!!
Then again, if we wrap everything in a box, our layouts become rather… boxy. A little boring, maybe?
So sometimes for emphasis, visual interest or plain ol’ fun, we’ll let certain elements break out of their container. Like the image and button in this mockup:
Historically, these sorts of design touches have been a little tricky to implement. Will you use negative margins? Absolute positioning? Transforms? How do you reserve adjacent space so it doesn’t overlap content?
CSS Grid helps a lot, but I still see developers getting a bit tangled in complex, nested grids and subgrids, trying their best to stretch inner containers around the breakout elements while respecting the content therein. The results can be very impressive, but a little intimidating.
The advice I give to those overwhelmed by this task: Decouple the containing shape from its content! Make a faux container, and put that where you want it to go.
Here’s an example implementation of the card from that mockup:
CodePen Embed FallbackIts HTML consists of just a few elements:
```
``` Let’s walk through how those are styled.
Step 1: Basic LayoutWe’re not going to worry about the breakout elements or containing shape quite yet. Let’s just get our elements in the order we want, using named areas to define their position:
.card { display: grid; grid-template-areas: "image" "title" "details" "action"; text-align: center;}.card__title { grid-area: title; /* title size, color, etc. */}.card__image { grid-area: image; place-self: center; /* image dimensions, rounding, etc. */}.card__details { grid-area: details; /* styles for detail content */}.card__action { grid-area: action; place-self: center; /* button or link styles */}
(The text-align and place-self properties are there to center-align content, per the mockup. May not be necessary for your own design or project.)
Here’s how that starting point should look:
Step 2: Adding the Faux ContainerNow, it’s time to add our faux container styles. We’re going to use a pseudo element, for two reasons:
.card and its children.We’ll go ahead and give it a color, round the corners, set its content so it will render, and give it a grid-area (just like its siblings in the previous step):
.card::before { background-color: hsl(271 88% 32%); border-radius: 1em; content: ""; grid-area: container;}
Ah, but there’s a problem: We’ve set the grid-area to container, but that isn’t accounted for in the template we wrote in the last step. Plus, we need this element to visually wrap its siblings, overlapping the other grid areas.
We could replace grid-area with grid-column and grid-row:
.card::before { /* using line numbers */ grid-column: 1 / -1; grid-row: 1 / -1; /* or existing names */ grid-column: image / action; grid-row: image / action;}
But then we’d need to manage our layout across two separate selectors. That can get a bit tedious, especially if we want to update the layout based on its viewport or container size.
Instead, let’s return to .card to combine our grid-template-areas with grid-template-columns and grid-template-rows. Using named lines, we can define a new area that spans these columns and rows, even though they’re already occupied by other areas:
.card { display: grid; grid-template-areas: "image" "title" "details" "action"; grid-template-columns: [container-start] minmax(0, 1fr) [container-end]; grid-template-rows: [container-start] repeat(4, auto) [container-end];}
(You may recognize the named lines technique from my previous article, or another I wrote way back in 2017.)
With that change, both content and container are in place:
Step 3: Breaking OutSo far, all we’ve done is create the world’s most over-engineered background-color. It’s time to break out of our containing shape.
To do that, we’re going to update our grid so that our image and action areas occupy two rows: One outside the container area, and another within. Let’s make a few changes:
image and action rows in grid-template-areas.grid-template-rows: One before container, one after..card { display: grid; grid-template-areas: "image" "image" "title" "details" "action" "action"; grid-template-columns: [container-start] minmax(0, 1fr) [container-end]; grid-template-rows: auto [container-start] repeat(4, auto) [container-end] auto;}
And voilà! Our container starts from the second row of image and ends after the first row of action:
Step 4: Gaps and PaddingOur layout’s coming along, but it feels a little snug. Normally, we’d fix that with the padding and gap properties. But in this case, padding will apply outside our faux container, and gap might throw off the alignment of our breakout elements (since they span multiple rows).
Instead, we’ll insert those spaces as their own columns and rows between our existing areas:
.card { display: grid; grid-template-areas: ". image ." ". image ." ". . ." ". title ." ". . ." ". details ." ". . ." ". action ." ". action ."; grid-template-columns: [container-start] 1lh minmax(0, 1fr) 1lh [container-end]; grid-template-rows: auto [container-start] auto 1lh auto 0.5lh auto 1lh auto [container-end] auto;}
(Note the placeholder dots in grid-template-areas representing where the gaps and padding we added will go.)
Phew! Now our content has room to breathe:
And with that, our faux container is done, and our card’s ready to style and polish.
It even falls back gracefully if either the image or button are omitted:
No image? No problem. 😎And we can use those same grid areas to adapt the layout to different viewports or containers. Here’s an example from the demo:
@container (inline-size >= 48em) { .card { grid-template-areas: ". image . . ." ". image . . ." ". image . title ." ". image . . ." ". image . details ." ". image . . ." ". image . action ." ". image . action ."; grid-template-columns: [container-start] 1lh auto 1lh minmax(0, 1fr) 1lh [container-end]; grid-template-rows: 1lh [container-start] 1lh auto 1lh auto 1lh auto [container-end] 1lh; }}
Which lets us enjoy that sweet, sweet horizontal real estate:
A Stepping StonePseudo elements have drawbacks. Historically, some browsers have been fussy animating or transforming them. And they’re tough to manipulate with JavaScript.
You could switch out the pseudo element for an empty <div> instead. But if you’re going to add markup anyway, you might as well style an inner container. In that case, subgrid is your friend.
Either way, I’ve found the process of decoupling containing shapes from their children a helpful exercise for understanding the potential of CSS Grid. It’s a fun separation of content and presentation, and a big step up from absolute positioning or static background images!
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
Here’s a pretty common pattern we see while designing responsive experiences or modernizing legacy applications. If you’ve used a cloud-based file manager, content management system or administrative UI, I’m sure you’ve seen it, too.
There’s a list of content. You can edit individual items by tapping or clicking their row, or you can select adjacent checkboxes to perform bulk actions.
Some versions of this require no JavaScript, showing persistent bulk actions as form elements.
A list of posts in WordPress (Note the “bulk actions” form)But most modern interfaces wait to reveal those controls until a selection is made. We can design big, touch-friendly bulk action controls, knowing they won’t monopolize the screen until selection has started.
Selecting from a table of transactions in YNABTraditionally, that sort of thing requires JavaScript. So it’s been fun to surprise some of our customers’ development teams by delivering prototypes that mostly function with HTML and CSS alone.
DemoHere’s a simple example:
CodePen Embed FallbackAt first glance, this looks pretty ordinary. Each row links to a (hypothetical) edit page, each has its own checkbox.
But once you check a box, some interesting things happen:
Before selectionAfter selectionSo… how’s it work?
MarkupThe demo consists of a form, a heading, a list of links and labelled checkboxes, and two buttons:
```
``
The button withtype="reset"reverts all of the checkboxes to their initial unchecked state when clicked. The other button submits the form, which willGETorPOST` all the checked boxes.
(I affectionately call this HTML “ancient,” since there’s nothing here you won’t find in the decades-old HTML 4 specification.)
StylesThe real star of the CSS show here is the :has selector. We can combine this with :checked to detect when a selection is in progress:
.example:has(:checked) { /* do things when boxes therein are checked */}
The demo uses this feature in two main ways…
Expanding the Checkbox Toggle AreaAs mentioned in the previous section, each item (or row) contains a link and a label with a nested checkbox:
```
``
Note the utility class on the` element, which lets us hide the element visually (while keeping it accessible to screen readers) using this classic technique:
.u-hidden-visually { block-size: 1px; border: 0; clip: rect(0 0 0 0); clip-path: polygon(0 0, 0 0, 0 0); inline-size: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; white-space: nowrap;}
(Spoiler: We’ll use this again in the next section.)
We compose the remaining, visible items with CSS grid using named lines (a technique I’ve written about before). Named lines are more verbose than grid-template-areas, but I find them more intuitive when I want several areas that overlap.
In this (slightly simplified) example, the full area overlaps both the label and link areas:
.item { display: grid; grid-template-columns: [full-start label-start] auto [label-end link-start] minmax(0, 1fr) [link-end full-end]; grid-template-rows: [full-start label-start link-start] auto [full-end label-end link-end];}
That may seem like a lot, but here’s the payoff: Now anytime there’s a checked box, we can stretch the label over the whole element by updating a single property.
.item__label { grid-area: label;}.items:has(:checked) .item__label { grid-area: full;}
Revealing Bulk ActionsBy comparison, showing the bulk action toolbar seems straightforward.
First, we make it sticky:
.actions { inset-block-end: 0.375em; position: sticky;}
Then, we hide that element when there aren’t any checked boxes:
.listing:not(:has(:checked)) .actions { display: none;}
And we’re done, right? Well, not exactly.
Unfortunately, display: none will hide those controls from everyone. This denies users of assistive devices important context for the form and its functions.
So let’s do the right thing and make a couple small changes:
display: none, let’s piggy-back on those u-hidden-visually styles from the previous section..listing:not(:has(:checked)) .actions:not(:has(:focus-visible)),.u-hidden-visually { /* same technique as the previous section */}
That’s all the major behavior accounted for. Everything else in the demo is presentational.
A Starting PointI wouldn’t consider this design shippable before exploring improvements that likely require JavaScript (for now, anyway). Things like:
But as a foundation for progressive enhancement or, in my case, an in-browser mockup, it’s really exciting (and fun) how far HTML and CSS alone can take us.
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
When it comes to Western languages, most long-form text you’ll encounter is either left-aligned (with an uneven, “ragged” right edge) or justified (with words spaced evenly across a line). But I’ve long avoided the latter in my web design work.
Why? Hyphenation.
To quote Matthew Butterick’s Practical Typography, “if you’re using justified text, you must also turn on hyphenation.” But hyphenation on the web can be tricky. In On Web Typography, Jason Santa Maria called hyphens: auto “crude,” warning that “the results are unpredictable.”
But over the holiday, I saw this Mastodon post from Nathan Knowler:
This is a fun little #CSS trio:
text-wrap: balance;hyphens: auto;hyphenate-limit-chars: 10;Allowing hyphenation can help when balancing text. The last property (only supported in Chromium) sets the minimum length of hyphenated words to 10 characters which helps avoid undesirable hyphenation of smaller words.
This inspired Aileen, Paul and I to take a fresh look at justified text using CSS.
Justify My ProsePaul suggested text from Alice’s Adventures in Wonderland by Lewis Carroll, a public domain work. In this example, I’ve aligned the first few paragraphs using four key CSS rules.
These two work in every browser:
text-align: justifyhyphens: autoWhile these are only supported in Chrome and Edge as of this writing:
text-wrap: pretty to avoid orphans and widows. Nathan’s post mentions balance, but that has a line limit that doesn’t work with longer paragraphs. (See Stephanie Stimac helpful overview and Ahmad Shadeed’s deeper dive.)hyphenate-limit-chars: 7 to discourage over-hyphenation. I doubt there’s any one, true value for this: It will depend on your design and content.And here’s the result:
CodePen Embed FallbackObservationsI was pleasantly surprised by the results in Chromium browsers at medium and large container widths. Hyphenation seems conservative and readable, yet there are no unsightly gaps or “rivers” between words. Safari and Firefox hyphenate a bit more frequently, but not distractingly so.
Comparison of a justified, auto-hyphenated paragraph as rendered in Chrome and Safari.Narrower widths are still pretty hairy, though. Chromium browsers are prone to larger gaps, while Safari and Firefox split up words a bit too aggressively.
Gaps between words in ChromeFrequent hyphenation in SafariTakeawaysLeft-aligned text will continue to be my recommendation and default approach. It’s more flexible and predictable when it comes to varying line lengths and content sizes and does not rely on automated hyphenation to maintain readability. From an accessibility standpoint, left-aligned text is easier to read for many people.
But when a design truly calls for finite, justified blocks of expressive typography, the combination of hyphens, text-wrap and hyphenate-limit-chars makes it a bit more viable than in years past. Just be sure to use responsibly, test thoroughly, and only apply when there’s adequate space and feature support:
@container (inline-size >= 30em) { @supports (text-wrap: pretty) and (hyphenate-limit-chars: 7) { /* Justify something here */ }}
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
Attributes and properties allow you to control how HTML elements function and read data about their state. Although the terms are often used interchangeably, subtle differences between the two can lead to unexpected behavior and bugs.
For example, when using the native HTML <input> element, there are three different ways to set its value.
<input value="Pesto" />
2. You can use JavaScript to get and set the value attribute:
const input = document.querySelector('input');input.setAttribute('value', 'Marinara');console.log(input.getAttribute('value')); // Logs "Marinara"
3. Finally, you can use JavaScript to set and get the value property:
const input = document.querySelector('input');input.value = 'Alfredo';console.log(input.value); // Logs "Alfredo"
So attributes and properties do the same thing, right?Well, kinda. Here’s where things start to get weird…
Imagine we have a form with the <input> element from above:
<input value="Pesto" />
A user comes along and decides to change the input’s value. They delete the word “Pesto” and replace it with “Bolognese.” Let’s see what happened to our attributes and values:
const input = document.querySelector('input');console.log(input.value); // Logs "Bolognese"console.log(input.getAttribute('value'); // Logs "Pesto"
Wait, what? Why is the attribute still “Pesto”!?
You can play with this behavior below. Try typing in the input and see how the attribute and property respond:
CodePen Embed FallbackNotice how the attribute never changes while the property responds to your typing. What’s going on?
Note: When you submit a form with an input, the value property will be used, not the attribute.
HTML vs. JavaScript When a web page is loaded in the browser, there are two different representations of the DOM:
Under the hood, the HTML DOM API creates a JavaScript object for each element and converts its attributes to properties. When the page loads, these two representations are in sync, but they can drift out of sync.
Try running console.log(document.createElement('input'));in your console. You’ll see an object with a long list of properties, including value (as well as a property called attributes.)
Here’s my over-simplified understanding of how this works:
setAttribute modifies the HTML code in the DOM. Calling getAttribute reads the attribute from the HTML code in the DOM.myInput.value you’re accessing a property of the JavaScript object representing your input.When do attributes and properties stay in sync?One thing that’s confused me for a long time is that sometimes, updating an attribute also updates the corresponding property (and vice versa), but sometimes it does not!
Most attributes and properties stay in sync: Updating an element’s id attribute will update its id property and vice versa. But some attributes and properties are special. As we saw above, the <input> element’s value is one of these special cases.
Why doesn’t the <input> element’s value stay in sync?The logic for when the <input> element’s value gets synced is weird. Here’s what I found in my testing:
I was very confused about this behavior. Thankfully, Valtteri Laitinen commented on this post with an explanation of what’s going on under the hood:
The
valueHTML attribute represents the default value of an<input>element, and the corresponding property isdefaultValue. Thevalueproperty represents the current value of a form element and has no corresponding HTML attribute.
Honestly, I still find this behavior very odd and confusing. This excellent article by Jake Archibald goes into greater detail and helped me understand how this works: HTML attributes vs DOM properties.
In practice, different attributes and properties work differently depending on how their specifications are written. There are a few more things to keep in mind when understanding these differences.
Do attributes always have a corresponding property?As far as I can tell, all of the officially documented attributes have matching properties. (That said, there are a ton of different elements and attributes, so if you’re aware of one that doesn’t, please let me know!)
However, you can also add your own custom attributes to HTML elements: <input custom-attribute="" />If you add a custom attribute, it will not have a corresponding property.
There are a few other special cases to be aware of:
formAction). Attributes are case-insensitive (formaction === FORMACTION === fOrMaCtIOn)class attribute gets renamed to className when used as a property.data- attributes get stored in a special dataset property.Do properties always have a corresponding attribute?No, sometimes properties don’t have corresponding attributes. For example, HTML elements have an innerHTML property but no innerHTML attribute.
Properties can contain non-string dataAnother difference between attributes and properties is that attributes are always strings while properties can contain other data types (like numbers, objects, boolean values, etc.)
For example, the <input> element’s maxLength property accepts a number. If you set the maxlength attribute in HTML it will be set as a string (<input maxlength="5">.) The browser will automatically convert it to a number when syncing it to the maxLength property.
What’s the deal with boolean attributes?Some attributes are known as “boolean attributes.” These attributes are either present or not present and represent either “true” or “false.” The checked attribute is boolean. If it is present at all, it is treated as true. The browser treats these all the same and renders a checked checkbox for each one:
<input type="checkbox" checked /><input type="checkbox" checked="" /><input type="checkbox" checked="true" /><input type="checkbox" checked="false" /><input type="checkbox" checked="maybe kinda sorta" />
To “turn off” a boolean attribute, it needs to be removed from the element completely by using the <input> element’s removeAttribute() or toggleAttribute() methods.
Which should you use? An attribute or a property?There are lots of edge cases that may lead you to use one over the other, but here’s my rule of thumb:
But this isn’t always true. Sometimes, it makes sense to break the rules.
Wrapping upWhew, I never thought I’d write so many words about attributes and properties! I realized several times while writing this article that I was still misunderstanding one or more aspects of how they worked, but I think I’ve finally gotten it. If you see something I got wrong, please let me know, and I’ll update this post!
Stay tuned for a follow-up article where I’ll break down the best practices for handling attributes and properties when writing custom web components.
Related readingHere are a few resources that helped me understand how this all works:
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
This series on AI is a co-authored by Megan Notarte and Jason Grigsby.
How we deploy AI in our work and product development may hold as much weight in shaping the risks and ethics as the specific models we choose. If nothing else, being thoughtful about our usage of AI can help reduce negative outcomes.
What is the AI being used to do?AI works best when you supply content and ask it to summarize or transform it in some way. Not only is the AI much less likely to hallucinate when you supply the content, but it is also less likely to generate something containing copyrighted material or biases beyond whatever bias was in the content you supplied.
What processes are in place to vet AI answers?Given the black box nature of AI, how do we know it is working as intended? How do we know that the answers given are accurate?
We should prefer processes that keep humans in the loop to vet what AI generates. If humans aren’t in the loop, then we need processes designed to spot check the output of AI to ensure responses are meeting an acceptable accuracy threshold.
What happens when AI gets something wrong?No AI model can promise 100% accuracy. AI will fabricate answers, introduce bias, and say things that are embarrassing, insulting, or worse.
These stories may be humorous when an AI bot tries to convince a journalist to leave his wife, but they may be costly when AI provides erroneous information to customers.
What are the worst case scenarios that can happen when AI gets something wrong?
It’s one thing if AI generates a false answer that a human can quickly identify as wrong and disregard. AI “hallucinations” are a much bigger problem when someone’s livelihood or freedom is affected.
Who will see the AI output?This is another way to consider the risk associated when AI gets something wrong by asking who will see the error?
If someone uses an AI chat bot for their own productivity, the only person who will see the AI output is that individual unless they choose to share it with others. Presumably, that person can vet the output before sharing it.
But every time the audience for AI output increases, the chances of something going wrong increases as well. AI used internally and only seen by a company’s employees will be lower risk than a system used by customers. Likewise, an AI integration being used by the general public is higher risk than a tool used by customers.
How focused is the use case?Slapping an AI chat bot onto a product isn’t enough. It will likely aggravate your customers more than it helps them.
The more focused the use case, the more likely AI will be appreciated by users. Adding AI to a product isn’t a goal unto itself. AI has to be in service of a user’s needs. Start by looking at your biggest customer pain points and see if AI can help.
How transparent is our AI usage to our users?We need to explain how we’re using AI to our users and give them the option to opt out of AI if possible. Ideally, AI would be an additional feature of our products, not a requirement.
How do we ensure our AI use is accessible?AI output, particularly the code generated by AI, is often not accessible. Hidde de Vries explains the challenge:
[An AI] systems’ success rate can be (and is usually) increased by training models specifically with very good examples…For accessibility, this data is hard to get by—most of the web has accessibility problems.
AI isn’t an excuse to ignore accessibility. AI features should be accessible to everyone.
How do we ensure user privacy?When we incorporate AI into our products, we must ensure we don’t leak a user’s private data inadvertently. We talked earlier about the need to understand how AI models use the data that users provide them, but we also need to evaluate our AI features with an eye on privacy.
We can’t be certain that AI generated output won’t contain sensitive information that the user has provided, so we shouldn’t publish that output without giving users with a chance to review it.
Is AI intended to replace humans?Not every job can be protected. But when we look at our work, we should consider the greater societal impact. Helping people live fuller lives and do their work more efficiently is something to strive for. Helping a massive company squeeze more out of their employees isn’t.
Our relationship to AI will evolveIt feels foolhardy to describe an ethical framework for a field that is evolving so quickly. The way we think about and interact with AI will inevitably change.
But no matter how AI changes, some version of the questions we ask here will remain relevant. We’re trying to understand how to utilize AI in a way that benefits our users and customers the most while reducing the risk of harm. Asking questions like these is the first step.
AI Ethical Framework Series Three Things We Agree On * AI Model Questions * AI Usage Questions*
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
This series on AI is co-authored by Megan Notarte and Jason Grigsby.
When people evaluate services they might integrate into their application or website, they often consider factors like cost, features, reliability, and performance. For AI, we want to extend those factors by asking questions about how the AI model is built and its impact.
What was the AI trained on?Many AI models have scraped content off the web without regard to the rights of the authors and artists. There are several lawsuits contesting this practice, and it is unclear what the outcome will be.
We should seek AI models that are transparent about what they train on and either own the rights to their training data outright or license it from copyright holders. It would be ideal if an AI was only trained on a company’s own data as it avoids many of these issues.
We prefer models that use specialized, licensed data—for example, scientific or medical research—over models trained on a large amount of general information. Specialized models tend to be more useful in their specific applications and produce more tailored results.
What steps have been taken to reduce bias?This is perhaps the thorniest issue. AI models reflect the biases of their training data. Without extensive evaluation of the training data, there is no way for outsiders to know what biases the AI may have learned, and most AI models won’t provide their training data.
When we evaluate different AI models, we should look at what they have published about known biases in their models, what they’re doing to mitigate them, and their guidance on how to minimize bias when using their systems.
If an AI company doesn’t acknowledge potential bias in their models, it should be considered a red flag. We’d rather work with a company that acknowledges bias and is trying to fix it than one that acts like it doesn’t exist.
We know that current AI models will have bias. Therefore, we must include safeguards in our AI usage to try to catch bias before it creates problems.
What happens to user data and prompts?The black-box nature of AI models makes it difficult to prevent them from divulging secrets. AI researchers tricked ChatGPT into revealing training data by asking it to “repeat the word ‘poem’ forever.” Prompt injection attacks like this might seem funny until it is your data being exposed.
We need to reduce the potential that AI will expose sensitive information. We can do so by looking for solutions that keep user data on a user’s device like Apple Photo machine learning or a local AI like WebLLM.
Unfortunately, most AI models are too large to run on a user’s device. Therefore, we need to review the AI model’s privacy policy and practices carefully. Does it isolate user data? Are user data and prompts used to train the AI model? The more data shared among users or used to train the model, the more likely the AI may leak it.
What is the environmental impact of the AI model?The data centers powering today’s AI models are energy intensive and consume vast amounts of water. This increase in energy and water consumption comes at a time when we’re struggling with the climate crisis and associated droughts.
Unfortunately, most AI models don’t divulge their energy and water consumption. Until that changes, we’re forced to resort to other signals that we hope indicate the company behind the model is committed to reducing its environmental footprint.
Does the company provide annual reports on their overall sustainability? Do they have a public commitment to being carbon and water neutral? If so, by when? Do they break out AI separately in their sustainability reports?
Where does the AI processing happen?One way to reduce the environmental impact and privacy concerns is to use the processing power of devices users already own.
Since 2017, Apple has shipped Neural Engines—a specialized chip for artificial intelligence often referred to as Neural Processing Units (NPUs)—in its phones and computers. Other manufacturers have also begun including NPUs in their devices.
If some or all AI processing can be handled locally on the device, we can reduce data center energy and water consumption. We hope to see more options for local AI in the future.
AI Ethical Framework SeriesStay tuned tomorrow for the final part in our AI Ethical Framework Series.
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
This series on AI is co-authored by Megan Notarte and Jason Grigsby.
We pride ourselves on doing right by the web and by the people who use it. So figuring out our approach to AI has been challenging. Some of us are excited by the possibilities. Others are skeptical or even hostile.
But in our discussions, we found that even people with differing viewpoints agree on three things:
Instead, we see it as inevitable that we will be asked to incorporate AI in our work whether that means including an AI feature in a client project or team members asking to use AI to help them complete their tasks. We’re curious about ways these tools might help us do our own jobs better.
We need some guidelines on how we answer these questions.
For example, Apple uses AI in the iPhone 15 camera to automatically detect portrait mode and to create better photographs. Photoshop’s new AI features can extend the background in an image to fit an aspect ratio. Not to mention the more transformative and life-changing AI-based solutions like Apple’s Personal Voice.
It seems undeniable to us that some AI is not merely good, but amazing. Desirable even. And without some of the downsides of other AI uses.
This is our initial attempt to define some criteria for evaluating decisions around AI. Our thoughts are still evolving. Our hope is that we can use these questions to guide us towards uses of AI that maximize its usefulness and minimize its harms.
Our framework consists of questions focused on two areas:
The next two articles in this series delve deep on these questions. Tune in tomorrow!
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
If you take nothing else away from this post, I want you to remember this: Write alternative text as if you’re describing the image to a friend.
I find people often get too wrapped up in what the “rules” are for alternative text. Sure, there are lots of things to be aware of, but almost all of them are covered under this simple guideline. If you were talking to a friend on the phone* and wanted to describe a meme you saw, you might say “There was this dog wearing safety glasses, surrounded by chemistry equipment, saying ‘I have no idea what I’m doing.’”
Keep it brief, but informative! Give the most important information and leave out unimportant details.
I know, I know, no one actually talks* on the phone anymore. If the very idea is stressing you out, you can replace “describing a meme over the phone” with “describing a meme to your getaway driver as you flee the scene of your latest heist, pursued by the detective that’s been hot on your heels since the job in Naples, so she’s understandably stressed and doesn’t want to look at a funny dog picture on your phone while she’s driving.”
Context mattersOf course, context informs your description. If I was describing the chemistry dog meme to a chemistry major, I might want to emphasize all the things the dog is doing wrong. If I was describing it to someone who works in a hazardous spill response team, I might mention that the dog is pouring one of the mystery fluids into a coffee mug. As an example, let’s consider this image from a Batman movie.
A fan-run Batman wiki, where the audience is likely familiar with the characters, might use this as alternative text:
Commissioner Gordon, wearing his signature trenchcoat, stands near the batsignal, considering whether to light it, alerting Batman.
In contrast, an article about cinematography, where the author chose this image as an example of a framing technique and Batman isn’t the primary focus, might highlight other features of the image.
In this shot, director Christopher Nolan has framed Commissioner Gordon, played by Gary Oldman, pensively looking away from the unlit batsignal towards the sky, with the city visible behind him, reminding the viewer of the stakes inherent in his decision to summon Batman.
And if Batman had a social media account, he might take a slightly different approach.
Gordon looking goofy AF after I dipped out while he was talking again LOL
All of these are valid alternative text choices for audiences in certain contexts.
You don’t need to say it’s an imageMost screenreaders will say “image” or “graphic” before reading the alternative text, so starting with “Image of X” is redundant. The only time you need to mention the image itself is if the medium matters, such as artwork and diagrams. For example, “a charcoal sketch of a cute kitten,” “the blueprints for the mansion we’re going to rob,” or “A chart showing a 50% decline in sales over three years.”
But you should include punctuationEric Bailey reminds us to add punctuation to our alternative text. If your alternative is only a single sentence, it might feel strange to include punctuation. But remember that it won’t be read in isolation, it will be read along with the surrounding text. Ending your sentence with a proper period or other punctuation will communicate to the screen reader how to transition from the alternative text to the following text.
Should alternative text describe race?Sometimes! Like everything, it’s contextual. I highly recommend reading “The case for describing race in alternative text attributes” by Tolu Adegbite, and “Thoughts on skin tone and text descriptions” by Léonie Watson. They both point out that by not mentioning race, we may be unintentionally reinforcing the idea that the unspoken default is white.
Think of it the same way we encourage the adoption of gender pronouns in profiles even for cisgender people. It’s not about whether anyone might be confused about what your pronouns are. It’s about normalizing the idea that everyone has pronouns and they may not match your expectations.
Decorative images don’t need alternative text, but your image probably isn’t decorative.It’s true that purely decorative images are allowed to use an empty string for their alt attribute. However, as Eric Bailey points out, your image is probably not decorative. In a nutshell, the term “decorative” means the image does not visually communicate information, not that it is used as decoration.
A spacer GIF is decorative. Image borders are decorative. A button that only contains an icon image is not decorative. This photo of the safe that we’ll be cracking in the mansion is not decorative. A company’s logo is not decorative.
What about a person’s avatar, displayed next to their name?This is a tricky case. If you have a person’s photo displayed right next to their name, and the alternative text only contains their name, then the screen reader will hear the person’s name twice, which adds no value. For example, at the top of this page, you can see my avatar next to my name. We’ve opted to leave the alt attribute empty on the avatar, because we don’t want screen reader users to hear “Image, Scott Vandehey, Link, Scott Vandehey.”
But… it’s not quite that simple. It depends on what the image shows. In “Writing great alt text: Emotion matters,” Jake Archibald makes the case that an avatar photo of himself on a conference site that showed him hiding behind a plant actually contained information that should be expressed in the alternative text, and opted for “Jake Archibald hiding behind a plant.”
As usual, context matters, and when in doubt, try reaching out to real users of assistive technology for opinions.
ConclusionI know I started out by saying you only need to remember one guideline, and then gave you, like, seven. But I stand by what I said. Don’t stress about crafting the perfect alternative text. Just write it the way you would describe the photo of the bag of glistening diamonds you just stole from the Duke of Chauntelburry as you drive along the coast after finally shaking the detective, the wind blowing through your hair, with your getaway driver still chuckling about that dog-doing-chemistry meme you described to her earlier.
Learn More:* Perkins School for the Blind, “How to Write Alt Text and Image Descriptions for the visually impaired” * The A11y Project, “Are You Making These Five Mistakes When Writing Alt Text?” * Axess Lab, “Alt-texts: The Ultimate Guide” * Bureau of Internet Accessibility, “8 Common Image Alt Text Mistakes to Stop Making” * Carie Fisher, “Accessible Images For When They Matter Most” * Steve Faulkner, “The Perils of Using Double Quotes Inside Alt Text” * Geoff Graham, “Just How Long Should Alt Text Be?” * Stefan Judis, “The CSS ‘content’ Property Accepts Alternative Text” * Shawn Lauriat, “How Learning ASL Improved My Alt Text” * Veronica Lewis, “Seven Myths About Alt Text” * Elaina Natario, “Alt vs Figcaption” * Scott O’Hara, “Contextually Marking Up Accessible Images and SVGs” * Adrian Roselli, “Long Alt” * Wren, “An Attempted Guide to Writing Effective Alt and Descriptive Text for Art”
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
In this episode of Fourcast, I sat down with Tammy Everts from SpeedCurve to chat about Google’s recent switch from First Input Delay (FID) to Interaction to Next Paint in Core Web Vitals and what it means for website owners.
I’ve always appreciated how Tammy can explain complex web performance topics in terms anyone can understand. It helps that Tammy is not only a recognized expert in web performance, but also in user experience, and she brings both of those perspectives to this discussion.
In this episode, we cover:
Tammy shares several recommendations for website owners including how to try performance metrics to key performance indicators for your business.
This episode is a must-listen for anyone looking to understand how INP impacts them and what they can do to improve their website performance.
Subscribe to Fourcast on Spotify, Apple Podcasts, YouTube, or wherever you get your podcasts.
Related links:* Tammy Everts — Website, Mastodon, Twitter * Time is Money: The Business Value of Web Performance by Tammy Everts * Web Performance Optimization Stats * Progressive Web Apps Stats * Web.dev case studies * Mobile INP performance: The elephant in the room * Does Interaction to Next Paint actually correlate to user behavior? * A Fairly Complete Guide to Performance Budgets presentation by Tammy Everts, Smashing Conference 2023
TranscriptJason: [00:00:00] Welcome to Forecast. I’m Jason Grigsby, one of the partners at Cloud Four, and we have an exciting episode for you today. As you may have heard, on March 12th, Google made a change to the way it ranks web pages in its search results. In particular, it replaced FID with INP in the Core Web Vitals that it uses to evaluate a web page’s performance.
Now, did that sound like gibberish to you? A bit of acronym soup? Well, don’t worry. That’s why we’re here today to clear that up. And we have a phenomenal guest to help us with it. Tammy Everts is a long time user experience and web performance expert. She wrote a book called time is money. The business value of web performance.
She helps curate WPO stats, which, stands for web performance optimization stats, and it keeps track of performance success stories. So if you’re looking for examples of how performance impacts business, you can go to that site and you can find really great [00:01:00] examples. It was also the inspiration for our own website, PWA stats, which does something similar for progressive web apps.
Not only is Tammy a sought after speaker, she’s also the co chair of the annual Performance Now Conference, which takes place in Amsterdam this November. Tammy works as the Chief Experience Officer for SpeedCurve. And more than all of that, Tammy is an exceptional human being and tremendous contributor to the web performance community.
Tammy, welcome to the show.
Tammy: Oh, what a nice intro. Thanks, Jason. I said, I laugh every time I hear I think it was a couple of performance. nows ago that somebody coined the term TLA three letter acronyms and how much in our industry we really like our TLA. So it was like TLA, FID, WPO. So yeah, a big part of my job is slowing down and explaining to people what the three letter acronyms are and, you know, evaluating, are they even helpful?
[00:02:00] Are they measuring what you need to measure? Like what, what do we actually learn from all these TLAs?
Jason: Well, that’s excellent because that’s, that’s really what we want to get into today. And I think we should start with some of the basics, like just to catch people up to speed. And let’s start with Core Web Vitals.
Like what are they and why should website owners care about them?
Tammy: Yeah. So a little history of Core Web Vitals. It feels like they’ve been around kind of forever in tech years. They’re really only about four years old. It’s a Google initiative that started in 2020. And the focus was to kind of take, we have this ever increasing swath of metrics to use to measure, you know, various things like rendering times and how pages are built and other things to do with web performance.
And to kind of simplify it because it’s pretty overwhelming and to simplify it down to a set of currently three metrics that are intended. To let you know how to measure performance from [00:03:00] the perspective of like what, what actually matters to users. And so right now, those three metrics are Largest Contentful Paint, which is the kind of loading metric.
It lets you know that the page is loading, something meaningful is happening on the page. Interaction to Next Paint, which is the interactivity metric. So it just lets you know how interactive the page is. Are there any interaction delays or responsiveness issues? And the visual stability metric, which is cumulative layout shift.
So short form LCP is Largest Contentful Paint. INP is Interaction to Next Paint and CLS is a Cumulative Layout Shift. So those are those. So those are the three metrics. They are among the page experience signals that Google Google factors into its search ranking algorithm. Hence all the fuss because when Google says something is part of its search algorithm, everybody sits up and takes notice in that respect.
[00:04:00] They’ve been really great for the performance community because they’ve gotten a lot of people other than performance engineers and developers to think about and care about web performance. So, like, kudos to everyone at Google on the team who develops and continues to maintain Core Web Vitals. But I think the thing that gets a little bit lost is that they’re just part of the ranking algorithm.
We don’t actually know how much weight they have. And today there, and there are other ranking factors like, like mobile friendliness or security or accessibility, absence of interstitials, like there’s all kinds of things that go into that. So focusing just on Core Web Vitals and kind of leaving those things behind is not recommended.
And also it’s really important to remember that, Since Core Web Vitals have been announced I think a lot of good things have happened in terms of people caring about performance and trying to optimize for those metrics, [00:05:00] but we don’t actually have any meaningful case studies that show us the impact of Core Web Vitals on SEO, and I’m kind of just saying that up front because inevitably it’s the question that people ask me, and unless, you know, maybe one of your listeners has one they can share with me, I would really love to hear it, but to date there aren’t any.
Jason: Yeah, it’s interesting. We were working with a client a couple of years ago, now maybe, maybe a year ago, I can’t remember, but they had an SEO firm that was seemed to know their stuff, right? Like they’re, multiple times where I talked to SEO folks, and I’m not so certain, but this, this group really seemed to know seem to be very knowledgeable.
And when I double check things seem to match up and they were incredibly focused on Core Web Vitals as a key thing that was going to help them in their rankings. And they would actually see as we started implementing faster pages and [00:06:00] started seeing Core Web Vitals go up, that they were actually seeing an increase for their search engine rankings and the amount of traffic they were getting.
Now I didn’t have access to any of that data. I was just hearing it secondhand. So I can’t. I can’t speak to a case study. I don’t know what difference it made. I don’t know if there were other changes but we did make a substantial increase in performance overall for them. That was part of what we were working on.
And they saw that reflected in, you know, in SEO and in traffic. So it, it makes sense to me that you know, to the degree to which the algorithm that Google uses is a complete black box. But you need every little edge that you can get that you want to care about Core Web Vitals if you’re a website owner. And it also makes sense for users.
Tammy: Absolutely. And kind of like to that point there are really good case studies around Core Web Vitals and other metrics, other business and engagement metrics. So if you go to web. dev and look at the case studies [00:07:00] that Google has collected and, and other places, you can actually see that you know, improving INP, improving LCP has also improved revenue conversions, time on site, like, you know, a swath of other metrics. So I don’t mean to say that, you know, that SEO can’t also be improved.
Jason: Right.
Tammy: I guess it kind of Mike, my colleague, Yeah, exactly. We just, we just can’t demonstrate SEO exactly, but we can demonstrate a lot of other helpful things, which you should also care about.
My colleague, Andy Davies, who some of your, your audience might know, he’s a performance person from years and years back. And he probably forgotten more about performance than most people will ever know. He has a really good breakdown where he talks about SEO as being about user acquisition.
So you should care about performance and SEO from an acquisition perspective, but then you should keep caring about [00:08:00] vitals and other, you know, performance metrics from a retention aspect. So in the short run, unfortunately, we’re kind of hearing more and more about companies, agencies, consultancies.
And, and I believe most of them are doing things that are above the board, but a few that are kind of gaming some of the Core Web Vitals to get that SEO boost. And it’s really kind of a short lived strategy because at the end of the day, it’s not going to get you retention. So, you know, trying to game your metrics doesn’t really get you very many places.
And also, like, even Google will tell you that the metrics don’t matter as much as the content on the page itself. So, having great metrics is not a substitute for original content and, like, really meaningful original content. So, I would always recommend, like, you know, it’s important to care about SEO, obviously, but don’t make your pages faster or optimize for your metrics solely for SEO purposes.
You do it for your users, as you said. [00:09:00]
Jason: Right. You, you did a good job of describing , the current three Core Web Vitals. But this is new as of March 12th. And it used to be instead of INP Interaction to Next Paint, it used to be FID. And I wonder if you could talk just, you know, briefly about what FID was or is, I suppose it’s still around and why there were problems with it.
Why did Google decide to replace it?
Tammy: Yeah so the one thing that I forgot to mention earlier is how these metrics are actually measured. Like what are the tools that we use to measure these? And so the Core Web Vitals are measurable in any real user monitoring tool. So basically any tool that you’re using on your pages that measures real user experiences are real in the way that actual users interact with your pages.
And Google also, in terms of the thresholds that it’s created, because I didn’t mention those. Google [00:10:00] has sort of recommended thresholds for the different metrics that they’re kind of good, needs improvement and poor. And the recommendations are to kind of achieve those numbers or ideally achieve the good number at the 75th percentile of your users.
So what that means is, for example for Largest Contentful Paint, which is that loading metric that kind of tells you, okay, when is the most meaningful visual element above the fold rendered? I know we’re not supposed to say above the fold, but I say it anyways. And so you, you want to know that it is rendering in under two seconds, which is Google’s threshold, and you want to know that it’s doing that at the 75th percentile.
So basically 75 percent of your users are getting that experience of LCP happening at two seconds or sooner. I just kind of wanted to get that out of the way. Because getting into talking about FID it’s an interactivity metric and a responsiveness metric that measures actual user interactions.
So, how [00:11:00] quickly the page responds to the first user interaction. Specifically, a click, or a tap, or a key press. So the thing about that was, it was a good first attempt just, like, understanding, like, it’s not just about how quickly the actual content renders, but how that content behaves when people interact with it.
So, it was a, it was a first step. But the gaps sort of started to appear pretty quickly where realize that it’s not measuring the overall responsiveness of the page because there can be multiple user interactions on a page and actually like that overall responsiveness really matters. Like 90 percent of the user’s time on the page is spent after it loads.
So you want to capture as many different interactions as are happening on the page. And another telling thing that kind of exposed maybe some of the weakness of of FID, first input delay, is my colleague Cliff Crocker did an analysis pretty early on with FID, [00:12:00] where he, among other things, looked at how FID correlated to business and user engagement metrics.
So in performance if you’re capturing real user data, you can actually create something that we call correlation charts where you correlate your performance metrics like FID or like start render or anything else with your business metrics like conversion rate or user engagement metrics like bounce rate.
Really any of the metrics that you can capture. So the idea is that if FID was meant to be a user experience signal and a user experience oriented metric, that any changes good or bad to FID should affect, you know, some kind of business or user engagement metric. It’s, you know, FID gets better, conversions get better, that kind of thing. And what Cliff found was that changes in FID really didn’t correlate with any changes in those metrics. And so we realized clearly it’s not quite capturing [00:13:00] exactly what we need to capture from a usability, from a user experience perspective. So in the background while, and I think it was pretty early days when a lot of people, including the Google folks who work on the vitals team sort of realized that these cracks existed and they have been exploring Interaction to Next Paint as a potential replacement for quite some time.
Cause these things obviously aren’t trivial to, you know, implement and introduce… Do you have any questions about anything this?
Jason: Yeah, so, it seems like the way that I have been thinking about the difference between the two is that FID or which I didn’t realize we were, we were pronouncing it instead of sounding it out.
But that FID was really just measuring like the first thing that somebody did on a site. So if, if somebody built a site you know, like, I, you know, we see this a lot where you’ve got a webpage and the webpage loads, and then like a bunch of [00:14:00] other stuff loads, like a bunch of other JavaScript loads later that the person could have, if they happen to click in that window between when that initial stuff loads and when the later things load, they could have a good experience for their first click, but their second click could be really slow because, you know, like the chatbots loading up or something of that nature. And the way that I understand INP is that it’s an attempt to sort of capture that entire experience better. Whether it does or not I guess remains to be seen, but that it actually is attempting to, you know, look at all of the clicks that somebody has on a webpage.
Tammy: Exactly, exactly. So INP, it still only focuses on clicks and taps and key presses.
So, you know, it’s, that’s kind of the extent of it. But it measures all of the user interactions on the page and then gives you a single value. So a good INP is under 200 milliseconds. Basically, it’s [00:15:00] saying that if a user is on your page and they’re clicking on various things, the sum total of all the response time for those various interactions should not exceed 200 milliseconds, which sounds like a lot because it’s a three digit number, but 200 milliseconds is
Jason: No…
Tammy: .2 seconds. So it’s really not very much time at all.
Jason: One of the things that I heard a lot about sort of last year as people were talking about this transition but then I haven’t really circled back. It looks like some of the folks at SpeedCurve may have done a little more analysis on this was to try to understand how many sites were doing fine with, with FID, but maybe, you know, failing INP. And it seems like there may be quite a few of them.
Tammy: So we haven’t analyzed our own customers because there’s sometimes issues with, you know, doing analysis of aggregated data and the kind of the agreements that we have with our customers, what we’re able to do. But [00:16:00] Cliff my colleague, my colleague Cliff again, did an analysis of the top million websites via the HTTP archive and kind of looking at sites that had good FID versus good INP. And what he found was that for FID. It was really easy to have good FID, like, almost 100 percent of desktop sites had good FID, and about 93 percent of mobile had good FID.
So those are really good numbers. And so, a lot of people were really complacent, but it almost kind of worked against FID because people just stopped thinking about it or caring about it. Everybody, everybody just kind of, it became a metric that was really easy to ignore because it was always going to be in the green for you. Looking at the numbers on for INP, however, kind of paints a different story.
So numbers are still pretty good on desktop overall, like for the top million sites, it’s something like 96 percent of [00:17:00] desktop sites have good INP, but for mobile it goes way down and only two thirds of mobile sites have good INP. So still, I mean, roughly 65 percent is pretty, it’s pretty good, but it’s not great.
Like I wouldn’t want, you know, I would still want to be sure that I’m not in that one too.
Jason: Like those are the ones that presumably have larger budgets and people spending, you know, working on them more professionally than maybe the smaller, you know, like a mom and pop e-commerce site or something of that nature.
Tammy: Correct. And then Cliff did some other interesting research for example, like just kind of looking at the meaningfulness of INP. We did find that INP does correlate more closely to business metrics and
Jason: Oh, that’s great.
Tammy: Things like that. So that was kind of just like, it’s almost kind of our first, it’s our go to whenever a new metric comes out, if it’s measurable and RUM, can we create a correlation chart to see if it actually, you know, kind of moves the needle on any of your [00:18:00] important other business metrics.
And then interestingly, Cliff also found that mobile INP matters even more than desktop INP. So there was an even stronger correlation. You know, it’s a good or bad INP and good or bad, you know, conversions or bounce rate or anything like that. So the, the challenging piece there is that INP is harder to optimize for on mobile, but it’s kind of, if you have a, like a large swath of your users that are coming, you know, to you via mobile, you’re really going to want to make sure that you are optimizing for them because you have, there’s, there’s more potential there to move the needle on your business metrics if you do.
Jason: I mean, it’s great too, if you’ve managed to make your site fast on mobile, then it’ll fly on a desktop site or desktop browser. Exactly. So as far as SpeedCurve and the to the degree to which you can talk about these things, like what are you seeing at [00:19:00] SpeedCurve with companies trying to adapt to INP or is it a big.
Concern. Is it something that people are struggling with or is it something that, you know, that they’re already well suited for,
Tammy: so in SpeedCurve, anything, a few other tools as well. We’ve had the ability to track INP for quite some time. So we were ready for the transition. We actually have a really good relationship with the Google team.
We meet with them once a month and kind of share what we see kind of in the wild and they share what they’re doing on their end. And that’s it. It’s really helpful and super collaborative. So. Yeah, companies have had lots of leeway to adapt.
So they’re the people who saw it coming, wanted to be ready for it, kind of ahead of the game. And they were, and then, you know, definitely a fair share of companies that realized maybe kind of got real for them, like maybe in January or February or even just now and kind of realizing that they’ve got some catching up to do.
And so, you know, [00:20:00] a lot of the conversations that I’m having because I talked with a lot of our customers pretty regularly is just around turning on tracking for INP, understanding what it’s measuring, and as importantly, understanding what it’s not capturing for you. And I can kind of go into that a little bit, if that’s something that.
Yeah. So it’s funny. So there was all the hype around IMP. I kind of jokingly started calling it the Barbie movie of performance metrics, because it was, it was. I’ve never actually seen, so I’ve been doing performance stuff for like 14, 15 years now. And I’ve never seen as much hype around a single metric as around INP.
If you were like, it was, it was released on March 12th. And if you were on social media, like tech, social media on March 12th, it was literally, you’re just scrolling. It was like, INP this, INP that, that, like. It would be really easy to take away from that. Like, Oh my gosh, this is the only metric that matters.
[00:21:00] I just need to focus on INP and forget everything else. And I, a little bit of that did kind of trickle over to me through, you know, kind of through the, through SpeedCurve and talking to customers. Some of the conversations I had were like, it’s okay. It’s just one metric among many, like it’s, you know, it’s a good way to track you know, if your INP is really poor, like, you know, and, and you’ve had pretty good, you know, SEO, you know, ranking, like you’ve been in that top 10 for a while and you get crawled and, you know, Yeah, that might actually, you might take a little bit of a hit from that for sure, but there’s room to recover. The other caveats around INP that people might not be aware of are that it’s, it’s a very narrow set of parameters that are, in terms of the cohorts that are being tracked. But it’s still a very large group. So what I mean by that is INP is only supported in Chromium based browsers on non iOS [00:22:00] devices.
So what that means is that it’s not captured in other browsers, even if you’re using a Chrome type browser on an iOS device, it’s not captured in that either. So it’s really important to know that and to look at your RUM data and see where your actual users are coming from. So that you can prioritize, like, how much do I actually need to care about this?
And I’m, and I say, and I’m not saying you shouldn’t care, but I’m saying just kind of how much. So for example, I was speaking with a customer last week and they were asking about INP. It comes up at every call. And when we looked at their RUM data, we realized, okay, well, half your traffic is coming from iPhones. A little chunk is coming from the iPad. Another chunk is coming from Safari. So it’s like, as soon as you saw that, it was like, okay, well actually only about maybe 25, 30 percent of their traffic was coming from a Chrome [00:23:00] browser on a non iOS device. So it’s still a pretty significant chunk of traffic, but you know, not everything.
So I guess the thing that I’ve been coaching people on is, definitely optimize for INP because Google search cares about it and you want to make sure that you’re showing up well in Google search results. It’s still like, I think something like 80 percent of, of market share in terms of search.
Jason: Right.
Tammy: So that’s kind of the SEO side of things. But then in the tracking side of things, don’t assume that what you’re measuring in RUM is capturing all your user experiences, because it’s, it’s really, really not. And so you could have a really huge black box around a big chunk of your users.
So that’s a, quite an enormous caveat that I , try to share with people.
Jason: Yeah, that makes sense. It’s one of the things I guess from like a just general industry perspective that I was, I’m hopeful that maybe some focus [00:24:00] on INP will help with is reducing the amount of JavaScript that is in pages and the amount to which we burden users with that. And I think you see that really particularly on the underpowered mobile devices where, you know, iPhones generally are more expensive, higher performance, you get the mid tier and low tier Android devices, and they don’t have the CPU capacity to kind of handle the amount of JavaScript that some sites are using particularly sites that are sort of built around a single page applications and sort of expecting the web browser to build the application on the fly. And those are actually the sites that I worry about the most when it comes to trying to fix INP, because it seems like they like, particularly like a single page application, maybe built in React and like with a ton of JavaScript in it.
It might be hard to [00:25:00] make the transition to having something faster that doesn’t have delays for, I, like, I don’t mean to be pessimistic, but it does seem like a bigger undertaking . And it does, you know, like like you said, even in that example, it’s still like a quarter of their users who might be impacted by, I guess you said a quarter were chromium users, not necessarily Android Chrome. But there are a lot of Android users. Yeah. Yeah.
Tammy: So, and, and a way to think about it is really mobile INP right now is Android INP. So if you are tracking INP from, from mobile, that’s, that’s kind of what you’re, you’re
Jason: Yeah,
Tammy: what you’re getting what you’re learning about.
It’s funny. I have just as you said, I have a almost eight year old iPhone seven. And to your point about JavaScript, like, I can tell, like, I almost want to play a game with myself where I’m when I’m using an app or, or visiting a site. And my phone starts to heat up, like [00:26:00] how many scripts are on, on the, the site.
Like you can just, it’s like, it’s like, I’m so sorry, CPU, you just keep doing your thing. You know, it’s, it’s kind of crazy.
Jason: So you mentioned that you’ve been sort of looking at INP from a different angle than I’ve really heard anyone else talking about it thus far, which is sort of researching and thinking about UX ramifications of INP.
Can you tell me a little bit about this? Like how do you see INP impacting UX?
Tammy: I guess the, the core thing that I am focused on when I talk about INP with people and when I investigate INP, is reminding people that it’s not an SEO metric, it’s a UX metric. Mm-Hmm. But the purpose of it is to measure.
Interactivity. So kind of just to what I said before, like the, if you’re not thinking about it that way, then you’re not really going to be able [00:27:00] to communicate the importance of it as well as you might be able to with other people in your organization. Because, you know, talk about these TLAs devs, engineers, other folks who are kind of deep in the weeds.
We throw around these terms and they don’t mean anything outside our little circle. So if you want to actually get other folks in your org to care about any metric, it’s finding that usability slash business angle. So again, it’s kind of going back to what I talked about earlier, like that first principle of like, Okay, we have a metric that claims to be a UX metric.
How can we correlate it to something in the business? So making sure that you do that. So you can talk about the metric in business terms. So we can say that, you know this page is really janky because it has, you know, a poor CLS score. Because CLS kind of measures how much the visual elements on the page are kind of moving around and it has poor interactivity because you’re clicking on things and they don’t [00:28:00] happen and all of those things have a real impact on real people and how they feel when they’re using your site.
And so I think it’s really easy to kind of lose sight of that whenever we’re kind of like, I need to make sure I’ve got an INP of, you know, 150 milliseconds, things like that.
Jason: Yeah, it’s this weird, I mean, this has been a challenge for a while, right? The, the idea of, going back to the YSlow performance rules and the YSlow extension, right, where is the goal to have a better experience or is the goal to get the top grade? The nice thing about Core Web Vitals is there, there seems to be a real emphasis on trying to create metrics that measure real user experience, like to the degree that we can, which I don’t know whether we ever truly can, but you know, get as close as possible But that it’s I think kind of human nature to just be [00:29:00] like, I want to pass these three things.
And call it good or get a hundred on lighthouse or whatever it is. Yeah,
Tammy: To that point, one of the things that has come out of some of the research we’ve done looking at correlation charts is again if you’re just going to unquestioningly, look at a threshold that Google or someone else has defined.
And I’m not, I think somebody has to create thresholds, if only as a starting point. It’s like somebody has to write that first draft and put it out there. And it’s a good place to start from. But if you’re only focused on that and you’re not actually looking at your own users and your own user behavior, you could be thinking that you’re fine and you’re actually not.
So what I, what I mean by that is as an example I was looking at some correlation charts that Cliff created as part of his INP investigation. And one of the things that jumped out at me was that looking at the swath of like, say conversion rate to [00:30:00] various INP times.
Jason: Mm-Hmm.
Tammy: We saw that. Oh, okay, great. As INP improves conversion rate also improves. That’s great.
Jason: Right.
Tammy: But it wasn’t always consistent with Google’s thresholds. Like so for example, one site actually it was a hundred milliseconds, so that was a hundred millisecond point, not the 200 millisecond point.
Jason: Wow.
Tammy: It was where we started to see a difference at some. For some other sites it was later on. For some it was like, it was. Pretty much dead on, which was, you know, kind of, it’s a testament to whoever at Google kind of did this meta research to kind of come to that, to that 200 millisecond threshold. But it’s important to remember that these thresholds that are recommended to us are recommendations.
They’re based on looking at metadata, like aggregated data across a lot of different sites. Not your own site. So you could think 200 milliseconds is great, but actually for your own site, it would be better for you to move more of your users over to that a hundred millisecond point, and see conversions go up overall for your [00:31:00] business.
There’s a term that we use in looking at correlation charts called the performance plateau. And that’s basically when conversion just sort of. Like, so you see a decrease in conversions when your site goes from like two seconds to three seconds, you know, it gets a bit slower.
And then it kind of stays at that lower conversion rate for. Four or five more seconds. You could think that making your site a second faster for a user or for the swath of users who are getting five seconds for INP or sorry, that’s terrible number, five seconds for largest contentful page, moving them over to four seconds is going to make a difference.
It won’t, it’s not, it’s not going to be until you get them off that plateau and back in that zone. Where making an improvement improves conversions. I don’t know if I explained that well, and I don’t know if the air drawing charts really help.
Jason: So so it sounds like one of the recommendations you would make for website owners is to [00:32:00] take these performance metrics and try to do that.
Correlation to connect it to the, the key performance indicators that matter to their business like conversion rate, things of that nature. Are there other recommendations you would make to people who might be worried about INP and what it means for their business?
Tammy: Yeah. So, I mean, the first one, the one you just said is huge, just validating that it’s a meaningful metric for you.
So as I said earlier, looking at your RUM data and kind of just seeing, do I even have a significant portion of users for whom this is super relevant. If you do, yes, validating it looking to see where what the threshold for your own site should be, so maybe it’s not 200 milliseconds, as I said, maybe it’s 100 milliseconds.
Optimize for INP like as much as you realistically can so as I said, you kind of at the top of the, of this conversation make sure your content, you know, your content matters too. So, you know, if you can get your metrics to [00:33:00] a pretty good point and it can like kind of decide what’s good enough for that particular page so that you’re not kind of overly optimizing it and, and not really making a difference. I would also say don’t just measure Core Web Vitals.
Tammy: So, for example, like, if you care about understanding actual user experience and you know that you need to measure all of your users then there are some other metrics that I would recommend checking out as well. So for example, long tasks time. It is It’s broadly supported, supported across browsers.
It’s measurable in synthetic tools and in real user monitoring tools. And what it measures is the slow JavaScript on your page. So any long, a long task is any JavaScript task that takes more than 50 milliseconds to execute and do all the stuff that it needs to do. So it’s a major cause of delayed responsiveness.
So it’s a, it’s a pretty good proxy for INP.
I would really [00:34:00] recommend tracking long task time. And then as a companion to that total blocking time, which measures blocking JS, and it’s kind of similar to long tasks, except that it’s only measurable in synthetic, and it’s going to look at just, like, all of the long tasks.
That are blocking rendering on your page and the nice thing about measuring total blocking time. I know in SpeedCurve we do this and maybe other tools do it as well. Is we actually show you all the long tasks on your page so you see which specific scripts are those, those long tasks slash blocking scripts.
And actually what their blocking time is. So those are really helpful to look at as companions to INP. And if you see discrepancies are big differences between the numbers that you’re getting in your long tasks and total blocking time versus what you’re seeing in INP. That discrepancy is probably because [00:35:00] long tasks and total blocking time are measuring all of your users and not just, you know, those chromium based ones.
Jason: Oh, oh, interesting. Right. Right. Okay. Yeah, that totally makes sense.
Tammy: And then I guess kind of the final one, that thing that I would really recommend to people is if they’re not familiar with the concept of performance budgets, using performance budgets to fight regressions is like an amazing tool, like I, you know, again, talk to a lot of companies and you know, the one thing that the fastest sites, the companies that are renowned for being fast, like Pinterest, Etsy, and other companies like that, the thing they have all in common is that they use some variation on performance budgets.
And so, a performance budget is simply you tracking a metric or a few different metrics looking at the tracking them over time, looking at [00:36:00] kind of what is maybe you’re the worst day you had over the last two to four weeks for that particular metric, what that number was. So, for example, say you had an INP of you actually, you know, achieved 200 milliseconds and you don’t want to get worse.
You want to make sure that, you know, if you get worse, you set a performance budget within whatever monitoring tool you’re using, and you tell it to alert you when things get worse. I work with Tim Kadlec, who also some of your audience might know of his work. He’s a great performance consultant and he used a really great analogy of like guardrails and breadcrumbs.
So he talks about using performance budgets and testing on each deploy and, you know, kind of like firing, when you do a test on a deploy, it triggering letting you know when you violated a performance budget. So you just know right away. As like guardrails and then kind of just tracking and having access to [00:37:00] all of your, your test data is being kind of like the breadcrumb so that you can kind of quickly triangulate, triage, figure out what went wrong and fix it.
So performance budgets are an amazing tool. If you’re not already using them. Oh my goodness. I, I, I could have a, whole other podcast talk.
Jason: I was actually going to recommend that you when I was doing some research for this podcast that I’d saw that you have some recent talks on performance budgets and videos of them are online.
And so if people are interested, they should check it out. Yeah. Go to YouTube and search for Tammy and find those performance budget talks.
Tammy: Yeah. I’ve done, yeah, I’ve done a few talks about them and, and it’s kind of been my main focus over the past few years. Like , it makes me feel like I’m not doing enough to know that there’s this great tool, not just in SpeedCurve, like other tools have it as well.
Like there’s this great conceptually, this great tool. That you can use just to know that [00:38:00] things aren’t working anymore and that you’re not as fast as you used to be you could even set performance budgets on things like the the number of scripts on your page or the total JavaScript time or total long task time total blocking time all of these things you can you can create these guardrails
Jason: We use them even though our site doesn’t, you know, it doesn’t have our site’s pretty performant and we don’t have a lot of changes, but you know, like it’s helped us. Our site’s on WordPress and Jetpack has like randomly started inserting things into our web page.
And then all of a sudden we see the numbers. We’re like, Oh, what happened? We didn’t change anything. We, you know, then we go look and figure it out. So yeah, I totally agree. Well, thank you so much, Tammy. It was wonderful. This was incredibly helpful. And uh, where can people find you?
Tammy: Oh, you can find me in a lot of places.
So I’m on Mastodon Tammy Everts or it might just be Tammy on Mastodon on the web perf server. You could find me on Twitter. I’m still calling it [00:39:00] Twitter. Yes. @tameverts and um, I have a personal site, tammyeverets.com. You can find me there as well and contact me through that. If you have any questions, I love talking about performance as you can tell.
Jason: Yes. Awesome. Well, thank you so much, Tammy. And uh, we’ll see you all soon.
Tammy: Thank you, Jason.
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
On a recent project, we were developing a feature that could save directories of files to the user’s file system. A coworker suggested I check out the “File System API,” but warned that it only works in Chrome. I’d never heard of it, so I searched CanIUse.com for “file api.” I got a confusing list of similarly-named results, some of which appeared to be duplicates.
There’s the File API, the File API (again), the File System Access API (which is marked as unofficial), the Filesystem & FileWriter API (also marked as unofficial), the FileReader API, the FileList API, the FileSystem API, the File API: name, the FileReader API (again), and the FileEntrySync API (marked as deprecated).
Well, that wasn’t much help. So I googled “File System API” and found myself on a Chrome Developers Blog post called The File System Access API: simplifying access to local files. “Sounds promising,” I thought! The first thing I saw was this highlighted section:
Note: The File System Access API—despite the similar name—is distinct from the
FileSysteminterface exposed by the File and Directory Entries API, which documents the types and operations made available by browsers to script when a hierarchy of files and directories are dragged and dropped onto a page or selected using form elements or equivalent user actions. It is likewise distinct from the deprecated File API: Directories and System specification, which defines an API to navigate file system hierarchies and a means by which browsers may expose sandboxed sections of a user’s local filesystem to web applications.
Feeling intimidated, I headed to Stack Overflow, where after a bit of searching I landed on a helpful answer that suggested I look into the Origin Private File System, but cautioned me about similarly named APIs:
Don’t confuse OPFS with the other filesystem and filesystem-esque APIs. MDN has a detailed rundown on their “File System Access API” page (though I feel the page is misnamed, as it covers multiple distinct separate API surfaces, and the way it’s written implies some features (like
Window.showOpenFilePicker()) have wide-support when the reality is quite the opposite… There’s Google’s older and now deprecated (but still supported)chrome.fileSystemAPI, originally intended for Chrome Apps and browser-extensions… There’s also the File and Directory Entries API, which has wide browser support for read-only access to the user’s local computer filesystem. Note that this API is distinct from, but extends, the original W3C File API which also defines theFileinterface.
Friends, I’m not going to lie to you. At this point, I spent a long time looking out the window and considered moving to the country to raise goats. 🐐 🐐 🐐
The APIsOkay. Deep breath. We’re going to get to the bottom of this. The good news is that while this is confusing due to there being multiple standards with some combination of the words “file,” “system,” and “API,” there are fewer than it seems at first, and they actually build on each other, adding layers of functionality. So let’s go on a bit of a tour.
Get the File API Cheat Sheet The sheer number of file-related standards and proposals are overwhelming! Get a PDF cheat sheet summarizing all your options when you subscribe to our free newsletter.
Email Address File APIUp first, we have the File API, a W3C1 draft standard2 for “representing file objects in web applications, as well as programmatically selecting them and accessing their data.”
TL;DR: it defines what a “file” is, and allows you to read it.
Using the File API, web content can ask the user to select local files and then read the contents of those files. This selection can be done by either using an HTML
<input type="file">element or by drag and drop. —MDN
What the File API does not provide is any way to interact with directories or write files to the file system. Those features will be added by successive standards.
Blob, File, FileList, FileReader, and the ability to create a URL from a FileFileReader APICanIUse lists the FileReader API separately, but it’s actually part of the File API. It’s the part of the standard that allows you to actually read the contents of a file. Without this, you have access to the file’s name, size, and type, but not the content, because it’s just a blob of binary data.
File Writer APISimilar to the FileReader API, the File Writer API was a W3C draft standard that would have extended the File API to allow writing to files from a web application. It was discontinued in 2014. I’m unable to find any reference to why, beyond some forum speculation it might have caused security problems. But don’t worry, the ability to write files was later added to the File System API (see below).
File Directories and System APIThe File Directories and System API (listed for some reason as Filesystem and FileWriter API in CanIUse) was another W3C draft standard that would have added functionality to the File API. In this case, it would have defined how to “navigate file system hierarchies, and a means to expose sandboxed sections of a user’s local filesystem to web applications.” It was also discontinued in 2014, and I’m also unable to find any references to why, though the bulk of what it proposed was later recycled into the File and Directory Entries API community proposal (see below).
File and Directory Entries APIThe File and Directory Entries API, is a WICG3 proposal4 that “documents the types and operations made available by web browsers to script when a hierarchy of files and directories are dragged and dropped onto a page or selected using form elements, or equivalent user actions.” It is heavily based on the now-discontinued File Directories & System API.
TL;DR: it extends the File API to understand how to read directories as well as files.
Confusingly, the MDN pages for this API talk about a sandboxed file system, even though this proposal does not cover that (see Origin Private File System below). I believe that’s because the sandboxed file system was included in the now-deprecated File Directories and System API that this was based on. Perhaps the MDN pages were originally written for that standard.
FileSystem, FileSystemEntry, FileSystemFileEntry, FileSystemDirectoryEntry, and FileSystemDirectoryReaderFile System APIThe File System API is a WHATWG5 living standard6 that “defines fundamental infrastructure for file system APIs. In addition, it defines an API that makes it possible for websites to get access to a file system directory without having to first prompt the user for access.” Also, it “provides access to a special kind of file that is highly optimized for performance” in web workers.
TL;DR: it creates a “bucket file system” (also known as the Origin Private File System, see below), allows you to access files in a file system, and allows high-performance file access for web workers.
Although this standard is very new, having only been created in 2022, it actually represents a migration of parts of the older File System Access API community proposal (see below). The parts that became this document are now an approved standard, while the parts the remained behind in the community proposal are still being worked on.
FileSystemHandle, FileSystemFileHandle, FileSystemDirectoryHandle, FileSystemWritableFileStream, FileSystemSyncAccessHandle, and StorageManager.getDirectory()Origin Private File SystemThe Origin Private File System (OPFS) is defined in the File System API. It is a sandboxed storage endpoint private to the origin of the page (meaning each web application has sandboxed storage) and not visible to the user. The standard says, “This enables use cases where a website wants to save data to disk before a user has picked a location to save to, without forcing the website to use a completely different storage mechanism with a different API for such files.”
TL;DR: it provides a sandboxed private file system for your web application without touching the user’s file system.
In our case, as we downloaded files from a directory on the server, we would store them in the OPFS until everything was ready, and then we would prompt the user to give us permission to copy those files from the OPFS to their file system.
File System Access APIThe File System Access API is a WICG proposal that “extends the File System API to interact with files on the user’s local device. It builds on the File API for file reading capabilities, and adds new methods to enable modifying files, as well as working with directories.”
TL;DR: it extends the existing APIs to finally allow for saving to the user’s file system and improves the ability to work with directories.
Originally, this proposal also contained the definitions for file system handles as well as the OPFS endpoint, but those portions were moved to the File System API in 2022, leaving behind the Picker methods as a proposal.
showOpenFilePicker(), showSaveFilePicker(), and showDirectoryPicker()Can I actually use any of these APIs?That’s an excellent question, and not a simple one to answer. Normally, CanIUse.com is our friend, but because all of these standards are at various points in the approval process, they enjoy varying degrees of browser support (including between features in the same standard!). CanIUse solved this problem by listing individual features of each standard separately. As a result, I recommend searching for the actual feature you want, such as showDirectoryPicker(), rather than the standard as a whole.
In the case of our client app, I found the older standards were quite well supported, but the newer features provided by the File System Access API were mostly only supported in Chromium browsers. This will only improve over time, but for now, you should be sure to test your work carefully across all browsers you need to support.
Why is this so complicated?Interacting with file systems is inherently complex. The browser needs to support actions ranging from “download a single file,” to “drop a folder containing a nested hierarchy of files and folders,” and applications ranging from “a paint program that can open an image, make edits, and save the changes,” to “a complete database in the browser.”
There are also security concerns. Should the browser be able to write to any folder on the computer? (Probably not.) Should the browser allow the user to unintentionally expose sensitive system files to possibly nefarious web applications? (Arguably not. Chrome won’t let the user select certain folders for this reason.) Does exposing the available disk space to the web application qualify as a security risk? (Yes, the standard says this can lead to fingerprinting.)
In my case—trying to save a directory of files from a server—some of the restrictions seemed like overkill. After digging into the standards a bit more, I have a greater appreciation for the complexity browser makers face.
ConclusionTo recap, the File API added the ability to read a file. The File and Directory Entries API adds the ability to read directories. The File System API adds the Origin Private File System, and the concept of “handles” that represent file system entries. Finally, the File System Access API adds new “picker” methods to prompt the user for access to their file system.
When I started down this path, I spent a long time trying to make sense of all these APIs with confusingly similar names. Some were deprecated, some said they only affected drag-and-drop operations, and they were all maintained by different groups. My typical destinations to learn more, CanIUse and MDN, were less helpful than usual and contained misleading or confusing information.
But my confusion is your gain. Hopefully, this post will save some other developers from abandoning the web for goat farming!
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
In college, I took a life drawing class and learned a helpful sketching exercise I still like doing today. While drawing last night, I realized that I use the philosophy behind this exercise in my web design process and day-to-day life.
The Sketching ExerciseThe exercise I learned helps you quickly draw accurate life drawings by leveraging three rounds of sketching with increasing time and attention to detail.
To get started, you need to find something to draw and grab a timer. (I usually use the one on my phone.) Find a pencil and a piece of paper, and divide the paper into three boxes. You’ll be doing a series of 3 separate timed sketches. (If you’d like, you can follow along at home!)
30 Second SketchThe first round is short! You have only 30 seconds to draw your scene. This isn’t nearly enough time, but that’s why it works!
The short duration forces you to get started quickly. There’s no time to hem or haw and get stuck in a creative block. You’ve only got 30 seconds. You’d better get started.
You’re going to make mistakes, but that’s good! There’s no way your 30-second drawing will be a masterpiece, so it’s okay if you get the scale wrong, mess up the shape of an object, or end up going off the paper. This is important: by making these mistakes early, you learn what not to do in your final drawing.
It also helps you to get a sense of your scene’s overall shape and composition. What needs to be big? What’s too small? What’s going to take a lot of time?
This phase doesn’t end with a beautiful drawing, but that’s okay: you’re learning important lessons about how to draw your object. Take a breather and review your sketch. What went well? What went wrong? What will you do differently in the next sketch?
1 Minute SketchYou only have a minute for your next sketch. That’s still really short, but it’s also twice as long as 30 seconds. And you’ve got a superpower: you’ve already done this drawing. Sure, you only spent 30 seconds doing it, and the drawing wasn’t great, but you learned about the general shape of your scene and what not to do.
The second sketching session is an opportunity to refine what you’ve learned. It’s still not enough time to do a good drawing (for me, at least), so moving fast and making mistakes is still okay. While the first sketch was a mad rush to get anything drawn, the second sketch allows you to spend a little more time planning your final drawing.
During this sketch I get a better sense of the overall composition and figure out which parts of the drawing will be the most challenging. I’m still not aiming to create a great drawing; I’m trying to gain a better understanding of my scene and how I’ll draw it.
5 Minute SketchYou’ve got 5 minutes for the final sketch (though I’ll often cheat and go longer if I’m having fun.) This still isn’t a ton of time, but at this point, you’ve already drawn your scene twice! You’ve got this.
This is the first point in the process where I start to focus on making a quality drawing. I’ve made a ton of mistakes in the first two drawings, so I know what not to do. I’ve also gotten a good sense of my scene’s composition and what parts will require the most time.
By the end of the 5 minutes (or longer if I cheat), I usually have a sketch that I’m pretty happy with!
What’s the point?The secret behind this exercise is that you don’t do your best work on the first try. If I skip the quick sketches and jump right into the final sketch, I spend more time making a worse drawing.
I still make all the same mistakes: I make something too big, my drawing goes off the page, or I do a lousy job. But instead of doing a lousy job in 30 seconds, I do a lousy job over 10 minutes or half an hour.
What the heck does this have to do with web design?My web design process is longer and more circuitous, but I find myself relying on the same underlying concepts, and I often find myself working through three distinct phases of increasing duration and quality:
This process is different from the sketching exercise: I often switch back and forth between these phases as new questions are discovered, and it takes a whole lot longer than six and a half minutes.
But the underlying philosophy is the same: take your time, do quick explorations that allow you to make mistakes, and learn about the problem you’re solving. Don’t jump into the finished product until you’ve made a couple of bad versions of it. Your first try usually isn’t your best.
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
Recently a client approached us to produce a digital version of a printed information packet. Making changes to this packet was costly and time-consuming, so they wanted to convert the sections of the packet into pages on a website, with a CMS to make updates easier. The client also wanted to retain the ability to print the whole thing, with the same design quality as the existing packet. We were able to get a lot done using CSS print styles, but because browsers don’t support the full suite of CSS print styles, it was clear we’d ultimately need to generate a PDF to get the print design we desired.
This started a journey into the world of HTML-to-PDF services, and I’m quite pleased with the solution we landed on. I’ve since started using it in another project, and I wanted to share it with you today. In a nutshell, we’re generating a website from a CMS using Eleventy and generating a PDF version of the website using DocRaptor.
To make this project easier to talk about without all the client-specific details, I’ve created an example repo. It takes a public-domain Sherlock Holmes story and generates both a website and a PDF. Let’s go through the process.
The PDF ServiceTo generate the PDF, we’re using an HTML-to-PDF API service called DocRaptor. I found it to be easy to use and I’m happy to recommend it, but there are alternatives out there, including Adobe PDF Services, WeasyPrint, and even the tool that DocRaptor is built on, PrinceXML. They all do roughly the same things, and it would be easy to swap out DocRaptor for another service.
The main thing you need to understand is that we’re going to make an API call to a PDF generation service, and the body of our request will be the HTML it will use to generate the PDF. That HTML needs to include not only all the contents of the PDF but also all the images and styles.
Let’s break this down into three parts: How we generate the HTML for the PDF, the JavaScript we use to generate the PDF, and the CSS we use to style both.
The HTMLThe example repo uses Eleventy to generate the website. I like Eleventy a lot, and I think it’s a good fit for this kind of static site, but you could easily replace it with any build tool you wanted, including none at all. This process would work just as well with a hand-written HTML file. The only thing that matters is that we have a single HTML file that contains all the content we want to end up in our PDF.
Our goal was to have both a website and a PDF. In the example repo, the website is a public domain Sherlock Holmes story, broken up with each chapter on its own page to make reading easier. But to generate the PDF, we need a single HTML file with all the content.
Eleventy makes it easy to do this with a single file:
{% include 'title-page.njk' %}{% include 'frontispiece.njk' %}{% include 'contents.njk' %}{% for chapterObject in collections.chapters %} <article class="new-page"> {% include 'chapter-header.njk' %} {{ chapterObject.content | safe }} </article>{% endfor %}
We’re manually including a few PDF-specific pages, such as the title page and table of contents. Then we loop over all the book content, which is in an Eleventy collection called “chapters.” We write out the contents of each chapter on the page wrapped in an <article> element. Here’s what this all-in-one page looks like on the website, though it’s worth noting that no one will be looking at this page, it’s just being generated so we can pass it to the PDF generation service.
The CSSOne of the best parts of this process is that once you write the CSS for the website, you’re 90% done with the CSS for the PDF as well. DocRaptor is powered by PrinceXML under the hood, which has very good CSS support. And, since all of the DocRaptor CSS is based on real-world specs for CSS print styles, almost everything you write for the PDF has the bonus of giving your website good print styles.1
I found that I was able to use all the CSS I wrote for the website and I only needed to add a single print stylesheet that contained some additional rules for DocRaptor such as page margins, hiding some web-only content, and adding page numbers.
The only “bugs” I needed to fix in my existing CSS were related to modern syntax that DocRaptor doesn’t understand just yet, like logical properties (I had to replace a few instances of margin-inline with margin-left and margin-right). Another common change was adding page-break-inside: avoid to keep images from breaking across pages, for example.
When testing CSS changes, I found that the browser’s print preview was often good enough, if I didn’t want to wait for another API call to generate an updated PDF. Another option in Chrome is to emulate print media, which will apply the print styles in the browser window. When testing the print layout in the browser, I found that the printed page was 816 pixels wide (8.5 inches at 96 pixels per inch), which means my content column, after subtracting the 0.75 inches of margins, was 672 pixels wide.
You can view the full CSS for the example site, but I’d like to talk about a few specific features.
Page MarginsYou can easily adjust the page margins in the PDF:
@page { margin: 0.75in;}
That’s actually standard CSS. You can read more details on MDN.
If you want to override the margins on a particular page you assign it a name, and update the rules for that page.
.full-bleed-page { page: full_bleed_page;}@page full_bleed_page { margin: 0;}
Page HeaderLike a printed book, I wanted to put the name of the story in the header for each page. This turns out to be easy, again using standard CSS:
@page { @top { content: 'A Study in Scarlet'; font-family: Merriweather, serif; margin-top: 1em; }}
The @top at-rule targets a section of the page appearing in the page margin itself. In our case, we’ve targeted the top section, added content, styled it, and given it a bit of margin from the top of the page. By default, the content will be centered.
Note that if you’re not careful, this content could overlap your page content.
Page NumbersAdding page numbers is a similar operation:
@page { @bottom { content: counter(page); font-family: Merriweather, serif; margin-top: 1em; }}
We’re adding content to the @bottom section, but rather than giving it a simple string of text, we’re saying it should use the value of the page counter, which DocRaptor defines for us.
Table of ContentsNow the table of contents takes a little more work. In our HTML, we have a simple ordered list with jump links to the appropriate sections of the document, like so:
```
``
Note the.print-only` class, which as you might guess hides that element from view in the browser. Also note the empty anchor tag it contains, which we will populate with a page number using CSS.
When this block of code is shown in the browser, we get a simple unordered list of chapter titles that are jump links to further down the document, with no page numbers.
Now, we add this CSS for the print styles:
.toc-item { display: flex;}.toc-item__title { flex: 1;}.toc-item__title::after { content: leader(dotted); /* add dot leaders */}.toc-item__page a::after { content: target-counter(attr(href), page); /* add page numbers */}
We make each table of contents item into a flex layout and assign all the space to the title.
We add dot leaders using generated content after the title using the proposed leader() syntax (which at the moment, I believe is only supported by PrinceXML!)
We insert the page number for the chapter using a clever bit of syntax that lets DocRaptor look up the page number that will contain the ID the jump link is targeting.
And then we get dot leaders and page numbers automatically added via CSS!
The JavaScriptNow, let’s talk about the Node script we use to submit the HTML to the PDF generation service. You can view the full script on GitHub, but I’ll walk you through the structure of what we’re doing here.
await generatePDF('dist/a-study-in-scarlet/index.html');
The first thing that happens is we call the generatePDF() function and pass it the path to our HTML file.
generatePDF()The generatePDF function is our one-stop shop for generating a PDF from an HTML file, but it farms out the work to several smaller functions for ease of maintenance.
const generatePDF = async (htmlPath) => { // Get the slug and path info for this HTML file const meta = getMeta(htmlPath); // Get the contents of the HTML file const html = await getHtmlFromFile(meta.htmlPathCWD); // Create a PDF from the HTML contents const pdf = await fetchPDF(html, meta.slug); // Create the output directory if it doesn't exist await fs.mkdir(distDir, { recursive: true }); // Save the PDF to a file await fs.writeFile(meta.pdfPath, pdf); console.log(`[PDF] Writing ${meta.pdfPath}`);};
First, it calls getMeta(), which returns information including the file slug and the full path info. Then it passes the HTML file path to getHtmlFromFile(), which reads the actual markup from the file and makes some changes we need for DocRaptor, like inlining the CSS and images. Then it takes the markup and passes it to fetchPDF(), which handles the actual API call to DocRaptor and returns PDF data. Finally, it writes the PDF data to a file.
Let’s take a look at those functions in more detail.
getMeta()Up first, we have getMeta(), which accepts a path to an HTML file and returns information about that file.
const getMeta = (htmlPath) => { // Strip `dist/` and `/index.html` from htmlPath let slug = htmlPath.slice(5, -11); // Special case for the root HTML file if (htmlPath === 'dist/index.html') slug = 'home'; // Create relative HTML path and PDF write destination const htmlPathCWD = path.join(currentDir, htmlPath); // Convert any slashes to dashes for the PDF filename const pdfSlug = slug.replace('/', '-'); // Create the PDF write destination const pdfPath = path.join(distDir, `${pdfSlug}.pdf`); return { slug, htmlPathCWD, pdfSlug, pdfPath, };};
We get back four pieces of information, which are all used later:
slug, which is the filename, is only used for error logging if something goes wrong.htmlPathCWD is the full path to the HTML file including the current working directory. We need this to read the contents of the file in the next function.pdfSlug is used for the filename of the PDF, and we’re just replacing any slashes with dashes.pdfPath is the final output directory of the PDF, which is our dist folder plus the PDF filename.getHtmlFromFile()Next, we have getHtmlFromFile() which is responsible not only for getting the actual contents of the HTML file but also for making changes we need for PDF generation.
const inlineAssets = unified() .use(rehypeParse, { fragment: false }) .use(rehypeInline) .use(rehypeStringify);const getHtmlFromFile = async (htmlPath) => { // Grab the HTML file contents as a string const rawHTML = await fs.readFile(htmlPath, 'utf8'); // Change the CSS URI to a path so it can be inlined let updatedHTML = rawHTML.replace('/style.css', 'dist/style.css'); // Change any image URLs to paths so they can be inlined updatedHTML = updatedHTML.replaceAll('/images/', 'dist/images/'); // Inline the assets return String(await inlineAssets.process(updatedHTML));};
Once it loads the HTML contents, it modifies any CSS and image URLs to file paths so they can be inlined. We inline the CSS and images because the only thing we pass to DocRaptor is the HTML itself. DocRaptor can load assets from public URLs, but during development work, none of our files were public, so we got in the habit of inlining them.
For inlining, we’re using a library called rehype-Inline, which is capable of inlining CSS, JavaScript, and images in HTML documents.
fetchPDF()Finally, we come to the meat of the process: Passing the HTML to DocRaptor, which will return a PDF.
const fetchPDF = async (html, slug) => { if (!docraptorApiKey) throw new Error('Missing DocRaptor API Key'); // Send HTML to DocRaptor to generate PDF const pdfRes = await fetch('https://docraptor.com/docs', { method: 'POST', headers: { Authorization: `Basic ${Buffer.from(docraptorApiKey).toString('base64')}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ test: docraptorTest, document_content: html, type: 'pdf', prince_options: { profile: 'PDF/UA-1', // Adds accessibility features like tagging }, }), }); if (!pdfRes.ok) throw new Error( `${slug}: ${pdfRes.status} ${pdfRes.statusText} ${await pdfRes.text()}`, ); // Extract the PDF from the response and return it const blob = await pdfRes.blob(); return Buffer.from(await blob.arrayBuffer(), 'binary');};
This is a simple fetch request to the DocRaptor API. You’ll need to define a DocRaptor API key and tell it whether or not to use “test” mode, which is free, but adds an overlay. The one extra option we’re defining is asking DocRaptor to use the PDF/UA-1 profile, which adds accessibility features.
This function returns the raw PDF data, which we then save to the file system, and hey presto! We have a PDF!
ConclusionI’m quite pleased with this process because each step adds to the previous ones without getting tangled up. The website doesn’t know anything about the PDF. It’s just a straightforward Eleventy site that happens to include a single-page version of the website’s contents. That website includes print styles, including a handful of rules that only work in DocRaptor, but are all based on standard or proposed CSS syntax. The PDF generation itself happens entirely in a Node script that can be updated, modified, or even replaced in the future without breaking the website.
I know this probably isn’t a common problem, but I hope this article helps someone else who might be looking at a similar request and isn’t sure how to get started.
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
Many organization looking to upgrade or replace a legacy application justifiably fear a long wait before they see a return on their investment. When your customers clamor for improvements, asking them to wait months—or years—to see changes may be asking too much.
It doesn’t have to be this way. Let’s explore how to achieve that modernization in steps that deliver value quickly, minimizing disruption to your users and organization.
Begin with APIsAs mentioned in the last article in this series, APIs (Application Programming Interfaces) play a crucial role. They act as the bridge between your existing application and the new one you’re building. Here’s how they empower an incremental approach:
Modular ModernizationIn an ideal world, you’d be able to launch the new, modern version of your application with all features on day one. But that can mean an unacceptably long wait for your customers and your organization.
Instead, identify modules of your app that can function independently as an initial step. Here’s how to identify features for your initial rollout:
Successful modernization efforts need organizational buy-in. By picking areas that have the most value to you and your customers will create momentum and enthusiasm for your endeavor.
Setting a solid foundationGarnering organizational buy-in and excitement matter because the first module of the your app that you modernize will have the most overhead. You’re not just building that portion of your app, you’re setting up the foundation everything that follows.
This can involve:
Remember, this initial planning doesn’t require having all the answers upfront. But neglecting it can lead to challenges down the road. A little upfront work can save you a lot of headaches later.
Integrating the new with the oldOne of the challenges of an incremental approach is managing user experience during the transition. The new and old application interfaces might create a temporary disjointed experience. Here are ways to minimize this:
Step by step to modernizationModernizing a legacy application can seem daunting. However, focusing on achieving incremental value through small steps makes the journey manageable. By delivering quick wins to users and stakeholders, you build momentum and support for the overall modernization effort.
Remember, you don’t have to navigate this journey alone. We offer free one-hour consultations to help you develop a strategy for modernizing your legacy application with minimal disruption.
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
Yesterday, I asked for arguments and data about why website owners open links in new tabs or windows. I’ve enjoyed the discussion and learned a lot from the reactions. People have strong feelings about how links should be handled and what behavior they prefer.
Unfortunately, no one has addressed the core question that I was trying to answer. Some of that is to be expected. This is a topic people like to talk about. Everyone has an opinion.
But I also wasn’t specific enough in my previous article. In a comment on the previous article, Ben LaCroix shared a summary from Chris Coyier that describes scenarios where it makes sense to open a link in a new tab—for example, “there is user-initiated media playing.” This is a good argument, but it isn’t what I’m looking for. I’m not particularly interested in UX-based rationale.
What I want to know is where’s the data?
It’s been nearly twenty years that we’ve been discussing whether or not to open outbound links in new tabs or windows. Could it really be true that no one has run an A/B test on it?
In an industry that tries to measure every minute action a user takes, no one has bothered to test whether opening links in a new tab or window increases business value? I find this hard to believe. Can anyone show me some data? Anyone?
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
Update: Please see my follow up article that clarifies what information I’m looking for.
A couple of months ago, Lynne d Johnson asked how content creators, marketers, and editors handle outbound links. The results of her LinkedIn poll and the comments on it surprised me.
My stance in the poll’s comments reflects advice I’ve long given to clients:
Despite the advice and a myriad of articles backing up these points, many still favor new tabs. So, this begs the question: Why?
Rationales I’ve encountered include:
Are there other arguments that I’m missing?
And more importantly, does anyone have data that supports the arguments for opening links in new tabs or windows?
I don’t remember the first time I was asked to open links in a new window, but I know it was early in my career. After nearly two decades of debating this practice, you’d expect to find a few case studies or academic papers supporting the approach. But I’ve searched extensively and I can’t even find any anecdotes that support this practice.
What am I missing? A significant percentage of web professionals believe that opening outbound links in new tabs or windows provides a benefit to site owners. Why? Is it a digital urban legend? Or are people sitting on internal data showing big benefits to this approach?
These are honest questions. I know the arguments against this approach, but what are the arguments and the data for it? If there are benefits that have been measured, I’d love to know about them.
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
This is a sponsored post. This the first time we’ve done this, and we only agreed once we found a topic that we would write even if it wasn’t sponsored. ImageEngine had no editorial control.
tl;dr; ImageEngine may be the easiest way to implement responsive images and is worth considering when you’re looking for a solution that will get you 80% of the way to optimization with minimal effort. They have a new, free developer tier you should try out.
I forgot about ImageEngineA few weeks ago, I had an engaging conversation with ImageEngine about a new, free developer tier they’re working on. They were curious about the hesitance from developers like myself to utilize their service, and I am ashamed to admit, even in situations where it would be a fit, ImageEngine hadn’t crossed my mind.
Last summer, I advised two clients grappling with web performance issues where images were the main culprits. Despite presenting various image optimization services that would work on their BigCommerce and WordPress sites, neither client moved forward due to the labor-intensive task of updating image sources in their templates.
In retrospect, ImageEngine would have made a lot of sense for these clients as a quick and easy way to get some big improvements in their image performance. For the BigCommerce customer, it still would have likely meant updating templates, but only with a prefix instead of full responsive images markup. For the WordPress customer, they could have installed a plugin, added their ImageEngine unique domain, and they would have been off and running.
Why did I overlook ImageEngine?But why wasn’t ImageEngine part of my initial recommendations? A few factors contributed to this oversight:
On that last point, I was concerned about what happened if device detection got something wrong?
Of course, striving for perfection can sometimes hinder progress. In the case of my clients, the quest for the perfect image solution led to months of unnecessary slow site performance. An 80% solution now is better than a perfect solution delayed indefinitely, and you can always do further optimization later.
ImageEngine’s StrengthsImageEngine excels in providing an immediate performance boost because of a few key differences in its approach:
ImageEngine Defaults to Optimized ImagesAt first, I didn’t get the benefit of this subtle difference between ImageEngine and other image optimization services. They all optimize images. Why does it matter if ImageEngine does it by default whereas others have to add something to the URL?
Let’s compare Cloudinary’s URL syntax to ImageEngine. If I wanted Cloudinary to retrieve an image from our server and optimize it, my URL would look like this:
https://res.cloudinary.com/cloudfour/image/fetch/f_auto,q_auto/https://cloudfour.com/wp-content/uploads/2024/01/3d-part2-r1.png
There are several flags in that URL that tell Cloudinary what to do. The fetch portion tells Cloudinary to retrieve the image from the URL in the second half; f_auto tells Cloudinary to convert to the best image format that the user’s browser supports automatically, and q_auto automatically optimizes quality.
By contrast, the ImageEngine URL to do the same thing would be:
https://3r3r223r.cdn.imgeng.in/wp-content/uploads/2024/01/3d-part2-r1.png
No flags are necessary. All you have to do is prefix your image path with the domain ImageEngine gives you, and the image is optimized.
I understand why other services don’t optimize by default. Sometimes people want to serve the original image. You may anger some customers if you pick the wrong level of optimization. Besides, adding parameters in the URL is not a big deal, and they can be tremendously powerful.
But it is also true that having images optimized by default makes the implementation just a little bit easier. And every little bit of reduced friction helps.
Optimization can be smarter using device detection and client hintsIf I had a magic wand, every image on the web would use responsive images syntax and use the best possible image format. But that’s not the reality. There are many websites that still only offer a single desktop-sized image that contains far too many pixels, and thus far too many megabytes, for a mobile device.
This is where ScientiaMobile’s background in device detection shines. ImageEngine not only recognizes nearly all devices, they also know the screen size of those devices. They can resize images for small screens based on that knowledge.
Is that resizing going to work as well as someone using responsive images syntax? Probably not. Could it make a mistake occasionally? Yes, but it will be rare.
So again, it isn’t the perfect solution, but it does make a big difference, and it does so with the minimal amount of effort.
And if you add one meta tag to support client hints like this:
<meta http-equiv="delegate-ch" content="sec-ch-width https://3r3r223r.cdn.imgeng.in; sec-ch-viewport-width 3r3r223r.cdn.imgeng.in; sec-ch-dpr 3r3r223r.cdn.imgeng.in; ect 3r3r223r.cdn.imgeng.in;">
ImageEngine will use client hints in the browsers that support them. With client hints, any of the worries about device detection inaccuracy go away.
Images are proxied by defaultImageEngine assumes you’re going to leave your images whereever they currently live. Nearly every image optimization service offers some version of a proxy as an option that can be turned on. But because this is all that ImageEngine does, it simplifies the setup and configuration of a new service.
ImageEngine’s simple integrationBefore I agreed to write anything about ImageEngine, I asked to try it. I installed it on a development server for our site. This site runs WordPress so I installed the ImageEngine plugin, and was presented with a form that only asked me for one thing: the ImageEngine domain for my account.
ImageEngine’s WordPress plugin contains several bits of help, but the only two options are the delivery address and a button to turn ImageEngine on.I figured once I set it up and turned ImageEngine on, there would be more to do. But there wasn’t. The settings screen stayed the same. I opened the advanced settings to peek at them and see if there was anything else to do. There wasn’t.
The plugin’s advanced settings let you set what directories to include, any exclusions you want to make, whether or not to use relative paths, and a field where you can add ImageEngine directives if you choose. I didn’t change any of these settings. I cleared our server’s cache, and just like that, ImageEngine was serving up all of our images optimized from its CDN.
In fact, the ImageEngine plugin is so simple and flexible that ImageEngine refers to it as the “Image CDN” plugin. It isn’t ImageEngine specific. You can use it for any other service that you need to prefix image URLs.
Not every integration will be as easy as the WordPress plugin. Some content management systems will require updating templates. For developers, there is support for a variety of different languages. You’ll have to assess your own situation to see what the integration path will look like. But there’s a decent chance that you’ll find it possible to get that first pass of optimization out the door with less effort.
Optimizing images furtherYou don’t have to stop with ImageEngine’s default optimization. ImageEngine supports resizing images and modifying the optimization settings via URL parameters like other services do. Using these features allows a developer with more time to implement full responsive images syntax and get further optimization gains.
This comforts the responsive images advocate in me. I can see scenarios where getting a quick performance win with the default ImageEngine configuration could help some site owners. And then later when the developers have the time, they could go into their templates and add the markup necessary to support responsive images fully.
No one size fits all solutionDon’t get me wrong. I’m not saying ImageEngine is the best at everything. They don’t have some of the advanced features that other systems do like AI capabilities or asset managers. There are inevitably going to be use cases for which ImageEngine isn’t the best fit.
But I think the idea that one image service is perfect for all scenarios is a fallacy. And I think the image optimization providers themselves know this. They know there are types of projects and customers that they are a better fit for than for others.
All I am saying is that ImageEngine is worth consideration. Don’t make the mistake I did and leave it off your list of options.
My device detection baggageIn the early days of mobile, I was a defender of device detection. I thought it was another tool in the toolbox, and if used well, there was no reason to avoid it even if we all have painful memories of websites stupidly using user agents strings to block non-preferred browsers.
Over time, my perspective changed. I still didn’t have any problem with device detection, but I came to believe that a solution’s default outputs mattered. It wasn’t that device detection was inherently bad. The problem was that it was too easy for smaller teams of developers to implement device detection poorly.
This is not too dissimilar to my perspective on React. Can you make React fast? Maybe, with enough time, money and expertise. Is that what most developers do? No. Going against the grain of a tool is difficult which is why I advocate for frameworks that are more performant by default.
Ironically, the reason why I avoided ImageEngine—my fear of device detection complexity—is the opposite of the reality. ImageEngine is simple by default. Much of that simplicity has to do with design choices and smart defaults. But at least some part of it comes from leveraging device detection to make smart guesses.
So I need to issue a mea culpa for ignoring ImageEngine, and in particular, for not suggesting it to our last clients last summer. I don’t know if ImageEngine will be right for your project. What I can say without hesitation is that you should check out it—especially in situations where you need a quick win on image optimization.
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
Upgrading a legacy application can seem daunting. This series will guide you through the process, highlighting strategies for a smooth transition without disrupting your current operations. We’ll begin with identifying your starting point.
Standalone App vs. Client-Server AppFirst, we need to know what type of app you have.
Modernizing Standalone ApplicationsFor standalone applications, assess the benefits of adopting a client-server model. Perhaps there are features that you could enable if you included a server in the mix. For example, Google Docs introduced real-time collaboration, challenging Microsoft Office despite having fewer features initially.
If a client-server model isn’t beneficial, consider porting your application to the web using WebAssembly (WASM), which allows applications written in languages like C++, C#, .NET, and many more to run in modern browsers. Adobe reused Photoshop’s existing C++ code base when it ported Photoshop to the web using WebAssembly.
Upgrading Client-Server ApplicationsIf your application includes server functionality, the next question to ask is whether or not there are existing application programming interfaces (APIs).
Without Existing APIsEarly web applications tended to intermingle business logic and the presentation layer. This intermingling becomes problematic because you can’t upgrade one without potentially breaking the other.
Therefore, the first task is to separate the business logic from presentation code. This might involve creating APIs for front-end interaction, allowing you to update the user interface with modern web technologies while maintaining the existing back-end.
After the front-end client launches, you can upgrade or replace the back-end. As long as the new server supports the same APIs as the old one, the front-end client will continue to work. That’s the big benefit of moving from a monolithic application to one built around APIs and microservices.
With Existing APIsIf your app already has APIs, you can leverage them to develop a modern web client even if the existing client uses a different technology. For example, we helped ImageQuix convert a Java client into a web app, and the new client mostly used the same APIs.
While it may be tempting to simply port your existing client to the web, you shouldn’t. Don’t limit your vision and your design to the way your old application looked and features it supported. The modern web provides many opportunities for a richer and more powerful experience for your customers.
Starting Your Modernization JourneyEvery journey begins by knowing both where we’re going and where we’re starting from. Identifying your application’s current architecture is the first step on your modernization journey. In the next article, we’ll talk about tactics for delivering incremental improvements—taking baby steps if you will—towards your end goal.
In the meantime, if you have an existing application that you need to modernize, we offer free one-hour consultations that provides you with high-level feedback on what your modernization path might look like.
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.
See our work
I see a recurring performance problem on many ecommerce sites—the most important images on the page are being lazy loaded when they shouldn't be. You’re better off not implementing lazy loading at all than implementing it incorrectly.
Part 1 of this series explored the browser’s built-in HTML and CSS form validation features. Part 2 enhanced the experience by layering in JavaScript using the Constraint Validation API. Part 3 dug into custom validation handling for a checkbox group leveraging the FormData API.
This article explores the ValidityState API, a powerful, approachable, and well-supported API we can use to define custom validation messages.
Feel free to view the demo for this article as a reference. The source code is available on GitHub.
Join along as we explore the following in this article:
I’m excited, let’s jump in!
ValidityState API overviewThe ValidityState API gives us access to an object containing all the states for an input represented as read-only boolean properties with respect to the input’s native validation constraints.
You can find a list of all instance properties on MDN. Below are the ones we’ll use in this demo:
badInput: Is true when the browser is unable to convert the value (e.g., a non-numeric value in a number input)patternMismatch: Is true if the value doesn’t match the pattern attribute regular expressionrangeOverflow: Is true if the value is greater than the max attributerangeUnderflow: Is true if the value is less than the min attributestepMismatch: Is true if the value doesn’t conform to the step attributetooLong: Is true if the value length exceeds the maxlength attributetooShort: Is true if the value length is less than the minlength attributetypeMismatch: Is true if the value format doesn’t match the type attribute (when type is "email" or "url")valueMissing: Is true when the required attribute is present and no value is providedvalid: Is true when all input validation constraints are satisfied, false otherwiseTo better understand the API, let’s look at an example. If we have an email input with type and required validation constraints:
<input id="customer-email" name="customerEmail" type="email" required aria-described-by="customer-email-error" />Code language: HTML, XML (xml)
We can use JavaScript to access the input’s validity property to get the ValidityState object:
const inputEl = document.getElementById('customer-email');console.log(inputEl.validity);Code language: JavaScript (javascript)
Which logs the following:
{ badInput: false; customError: false; patternMismatch: false; rangeOverflow: false; rangeUnderflow: false; stepMismatch: false; tooLong: false; tooShort: false; typeMismatch: false; valid: false; valueMissing: true;}Code language: JSON / JSON with Comments (json)
You’ll notice the valid property is false and the valueMissing property is true. This means the field value is empty and fails the required validation constraint. If we were to satisfy the required constraint by entering an invalid email value, say “asdf,” the ValidityState object would update as follows:
{ badInput: false; customError: false; patternMismatch: false; rangeOverflow: false; rangeUnderflow: false; stepMismatch: false; tooLong: false; tooShort: false; typeMismatch: true; valid: false; valueMissing: false;}Code language: JSON / JSON with Comments (json)
Above, the valueMissing property is now false (the required constraint is now satisfied), but the typeMismatch property flipped to true because “asdf” does not satisfy the type="email" validation constraint. The valid property is still false.
And that’s it! We were just handed the critical piece to the puzzle.
Accessing an input’s ValidityState object is the key to providing custom, more accessible validation messages, enhancing the user experience over the default generic messages that may not be WCAG-compliant.
What is the customError property?You’ll notice there is a customError property that we’re not using in the ValidityState object. The customError property is true if a custom validation message is specified using the setCustomValidity method. Combined with the reportValidity method (which we don’t want to use), the built-in error message bubbles can be customized. Since we’re using a more accessible custom design for our validation messages, we don’t need to worry about the customError property and related methods.
Let’s add more demo fieldsWe can add more demo fields to see different ValidityState properties in action. Below are the input fields I added to the demo…
URL:
<input name="demoUrl" type="url" required>Code language: HTML, XML (xml)
Minimum three-character text value:
<input name="demoTooShort" type="text" minlength="3" required>Code language: HTML, XML (xml)
Maximum five-character text value:
<input name="demoTooLong" type="text" maxlength="5" >Code language: HTML, XML (xml)
Even number between 10 and 20:
<input name="demoRangeEven" type="number" min="10" max="20" step="2" required>Code language: HTML, XML (xml)
Odd number between 11 and 21:
<input name="demoRangeOdd" type="number" min="11" max="21" step="2">Code language: HTML, XML (xml)
Special 3-5 digit code:
<input name="demoPattern" type="text" pattern="[0-9]{3,5}" minlength="3" maxlength="5">Code language: HTML, XML (xml)
All of the new demo fields were added inside a new “More Demo Fields” fieldset:
A series of input fields with various validation constraints were added to the demo form.Adding custom validation messagesLet’s create a new function, getValidationMessageForInput, to handle the custom validation message logic. It will accept an input element as the only argument and return a string:
/** * Returns a custom validation message referencing the input's ValidityState object. * @param {HTMLInputElement} inputEl The input element * @returns {string} A custom validation message for the given input element */const getValidationMessageForInput = (inputEl) => { // Custom validation message logic will go here.}Code language: JavaScript (javascript)
To start with, we can return an empty string if the input’s ValidityState valid property is true:
const getValidationMessageForInput = (inputEl) => { // If the input is valid, return an empty string. if (inputEl.validity.valid) return ''; // The rest of the custom validation message logic will go here.}Code language: JavaScript (javascript)
For the rest of the custom validation message logic, we can consider a couple of different patterns. The first is to organize the messages by ValidityState property, for example, for the valueMissing property:
const getValidationMessageForInput = (inputEl) => { // If the input is valid, return an empty string. if (inputEl.validity.valid) return ''; if (inputEl.validity.valueMissing) { return 'Please enter a value'; } // The rest of the custom validation message logic goes here.}Code language: JavaScript (javascript)
While perhaps the most straightforward, this pattern is more limiting because we cannot craft field-specific messages; they will feel too generic. Let’s add an extra conditional check to organize them by input field name instead.
Using the customerEmail example from above, we can do something like this:
const getValidationMessageForInput = (inputEl) => { // If the input is valid, return an empty string. if (inputEl.validity.valid) return ''; /** * Customer email validation constraints: * - required * - type=email */ if (inputEl.name === 'customerEmail') { if (inputEl.validity.valueMissing) { return 'Please enter an email address. (This field is required.)'; } if (inputEl.validity.typeMismatch) { return 'Please enter a valid email address.'; } } // The rest of the custom validation message logic goes here.}Code language: JavaScript (javascript)
Better! This pattern gives us the flexibility to write unique validation messages for each input field’s specific ValidityState property, for example:
/** * Purchase date validation constraints: * - required * - type=date * - min * - max */if (inputEl.name === 'purchaseDate') { if (inputEl.validity.valueMissing) { return 'Please enter a purchase date. (This field is required.)'; } if (inputEl.validity.typeMismatch) { return 'Please enter a valid purchase date.'; } if (inputEl.validity.rangeUnderflow) { return 'The purchase date must be within the last calendar year.'; } if (inputEl.validity.rangeOverflow) { return 'The purchase date cannot be a future date.'; }}Code language: JavaScript (javascript)
If we don’t want unique validation messages for each ValidityState property, we can collapse multiple property checks into a single conditional and return only one validation message:
/** * "Odd number between 11 and 21" validation constraints: * - type=number * - min * - max * - step */if (inputEl.name === 'demoRangeOdd') { if (inputEl.validity.valueMissing) { return 'Please enter a number. (This field is required.)'; } if (inputEl.validity.badInput) { return 'Please enter a valid number value.'; } if ( inputEl.validity.rangeUnderflow || inputEl.validity.rangeOverflow || inputEl.validity.stepMismatch ) { return `The value should be an odd number between ${ inputEl.getAttribute('min') } and ${ inputEl.getAttribute('max') }.`; }}Code language: JavaScript (javascript)
You’ll notice you can even use the literal input attribute values within the validation messages if it makes sense, as I did above, using the input’s min and max attribute values:
return `The value should be an odd number between ${ inputEl.getAttribute('min')} and ${ inputEl.getAttribute('max')}.`;Code language: JavaScript (javascript)
This would generate the following validation error message:
The value should be an odd number between 11 and 21.
For the “Odd number between 11 and 21” number input field, if a value does not meet the min, max, or step validation constraints, the validation message “The value should be an odd number between 11 and 21” displays.We’ll apply this pattern, organizing the messages by input field name, for the rest of the form fields. I won’t include them all here, but you can peek at the getValidationMessageForInput source code file if you want to see them all.
How can we ensure the messages are accessible?We did most of this work when we set up the custom design for the validation error messages. The extra detail to consider is ensuring the custom messages explain the reason for the error and provide helpful suggestions. You can find more resources focusing on writing helpful error messages later in this article.
The only other addition we should make is a fallback message if no conditionals match. At the bottom of the function, we can return the built-in validationMessage as the fallback message:
const getValidationMessageForInput = (inputEl) => { // If the input is valid, return an empty string. if (inputEl.validity.valid) return ''; if (inputEl.name === 'customerEmail') { // Custom validation messages for customer email. } if (inputEl.name === 'purchaseDate') { // Custom validation messages for purchase date here. } // Follow the same pattern for the rest of the input fields. // If all else fails, return the default built-in message. return inputEl.validationMessage;}Code language: JavaScript (javascript)
Cloud Four’s latest insights and articles, straight to your inbox Email Subscribe Updating the demo to use custom validation messagesAlright, it’s time for the real heavy lifting…just kidding! Lucky for us, updating our demo to use our new custom validation messages means only updating a single line in our existing code.
In the updateValidationStateForInput function introduced in Part 2, change the errorEl.textContent value from inputEl.validationMessage (the browser’s default generic messages) to our new getValidationMessageForInput function:
const updateValidationStateForInput = (inputEl) => { // Existing code from Part 2 here…- // Use the browser's built-in localized validation messages. - errorEl.textContent = inputEl.validationMessage+ // Use custom validation messages.+ errorEl.textContent = getValidationMessageForInput(inputEl)};Code language: Diff (diff)
All done! The new customized validation messages can now flow right into the existing experience.
Below you can see a few of the custom validation messages in action (or you can view the live demo):
The first name, last name, and email fields with custom validation messages. The new demo fields with custom validation messages. What about localized validation messages?If we recall from Part 2, we previously used the input element’s validationMessage property for the “error” element textContent value. This ensured we received a built-in localized validation message.
With custom validation messages instead, it begs the question, are the validation messages no longer localized?
As it turns out, modern browsers can translate our new custom validation messages! I tested the following browsers:
For example, below, I recorded Safari translating the demo into Spanish. You’ll notice the browser translates the custom validation messages as they update for the “URL” and “Minimum three-character text value” fields:
Safari (shown in the video), Firefox, Chrome, and Edge will all translate the custom validation messages as they are updated using their built-in translation features. All browsers I tested provided a similar experience, including the millisecond moment when the English message shows before the browser translates it. It’s a bit annoying, but it makes sense that the text needs to make it into the DOM for the browser to translate it.
When I tested with VoiceOver + Safari, the screen reader would only announce the translated version.
If the browser translation features are insufficient for your project needs, you can look into implementing a more in-depth localization strategy. Here are a couple of articles that might be helpful, “How to conduct website localization: Don’t get lost in translation” by Julia Rozwens and “Internationalization and localization for static sites” by Sam Richard.
Wrapping upWe did it! We learned about the ValidityState API and how we can use it to write custom, accessible validation messages. The best, most inclusive user experiences are a balance between technical implementations and human end-user considerations. User needs come first.
Thanks for following along with the article series. It was a joy exploring how to enhance the form validation experience progressively. Until next time!
More resources* When life gives you lemons, write better error messages by Jenni Nadler
* Designing Better Error Messages UX: Establish Stop-Words For Your Error Messages by Vitaly Friedman
* Harvard University: Provide helpful error messages
* Accessible Web: How should I write form error messages?
* Curious about the type="email" basic validation regular expression used by browsers?
* Need more restrictive email validation? Add the pattern attribute to email input types.
* Shoutout to Chris Ferdinandi, who wrote an article using the ValidityState API a few years ago; great minds think alike.
Missed an article in the series?I’ve got you! Listed below are all of the articles from the series:
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.See our work
While I wasn’t looking, an elastic hover/active effect I shared on CodePen was viewed more than 11,000 times. Here’s how it works!
When developing websites, I often create the same boilerplate structure over and over. By creating a custom scaffolding script, I can automate this boring task and save a lot of time.
We’ve all encountered slow websites, sometimes forcing us to abandon our carts to head for speedier competitors. But knowing exactly what’s slowing down your own site and how to fix it can feel like searching for a needle in a haystack If you’ve felt the daunting task of improving your site’s speed weigh heavily on […]
Parts 1 and 2 of this series explore the browser's built-in HTML & CSS form validation features and how to progressively enhance the experience by layering in JavaScript. This article continues the exploration, focusing on a use case not handled natively: a checkbox group.
Are you tired of the same old meetings? Feeling stuck in a communication rut? In our latest Cloud Four Spotlight, we were fortunate to be joined by two incredible guests, Elise Keith and Dave Mastronardi, who shared their insights on revolutionizing how we approach meetings, foster creativity, and communicate more effectively. The New Rules for […]
In Part 2 of this series, we take the base HTML and CSS form validation experience and progressively enhance it by adding JavaScript and the Constraint Validation API while also addressing accessibility concerns.
Browsers nowadays have built-in form validation features that make JavaScript-only solutions unnecessary. Let's explore what this might look like using progressive enhancement techniques.
Can our GIF-like embeds support playback controls, alternative text, and reduced motion?
The block editor is super powerful, but how do we bring in dozens or hundreds of external HTML and CSS patterns?
The new Zelda game uses repeated patterns to build a cohesive world. Let's write code to generate these patterns and then print them with a robot!
Is it possible to center-align text content vertically when it’s shorter than a floating image?
Animation can help inform user about changes in your app. And the View Transitions API makes adding animations easier than ever.
In a recent interview, I had the opportunity to sit down with Paul Hebert, a talented Cloud Four senior designer and developer, to discuss a recent case study involving the creation of an interactive tool for exploring APIs for Cloudinary. Our conversation delved into the challenges faced, the process undertaken, and the valuable lessons learned […]
If you shop online, you’re probably familiar with product listings. These long scrolls of available stock are as commonplace in ecommerce as shelves are in brick-and-mortar stores. Because long lists get overwhelming quickly, we often divide our products into categories and add sorting, filters, search and/or comparisons. These features help customers discover the right purchase […]
Megan and Jason discuss the challenges of image optimization for ecommerce, where image quality directly impacts product returns. Compressing images too much can lead to loss of important image quality that can impact purchase decisions, resulting in disappointed customers and returns. Furthermore, slow-loading webpages caused by image-heavy pages increase the environmental impact and costs.
In our latest Cloud Four Spotlight, I had the pleasure of speaking with Andrew Berkowitz, a seasoned entrepreneur who has achieved remarkable success in his career. From founding TeamSnap to navigating its growth, overseeing various roles, and eventually moving on to his current venture, Suggestion Ox, Andrew brings a wealth of experience and insights to the table.
Ecommerce brands could potentially reduce returns by up to a third—saving billions of dollars—by providing more accurate product images according to an independent survey funded by Cloudinary. This survey reminded me of a conversation with Colin Bendell that I keep thinking about. Between the survey and that conversation, I’ve changed my mind about some of […]
Hey there, web enthusiasts! We’ve had the pleasure of partnering with some big brands to revolutionize their websites and create extraordinary user experiences. But hey, we’re not too proud to admit that we’ve hit a bit of a downturn lately. That’s where you, our awesome community, come in.
The Graphics Interchange Format (GIF) was released in 1987, which predates the first web browser. They remain the most popular format for short, autoplaying, silent animations in spite of their beefy file size and limited color palette. We’re long overdue for an alternative, which begs the question: What replaces the animated GIF?
Some say video formats are the clear successor, which makes sense… video is, by definition, a sequence of images. But there are some drawbacks:
autoplay, loop, muted and playsinline attributes to achieve similar behavior.video element exposes more playback control possibilities, which can be good for accessibility, but it lacks an alt attribute for alternative text. (The title and fallback content don’t seem to be exposed to assistive devices in a similar way, but maybe aria-label or aria-labelledby would work?)Surely the file size savings make up for all that, right?
Sometimes!
Let’s try some newer formats!I made grigsroll.gif a while ago in loving tribute to our CEO (and resident image performance expert) Jason Grigsby. It’s 448 KB in size:
If this isn’t worth half a megabyte of your data plan, I don’t know what is.Now let’s compare to some alternative formats with decent browser support (sorry, JPEG-XL) and better compression (sorry, animated PNG). I created these versions from the command line using gif2webp, ffmpeg and libavif for an honest comparison.
Here are the results:
AVIF or WebP (depending on your browser) WebM or MP4 (depending on your browser)
Comparison of image format file size savings| Format | Size | Savings | | --- | --- | --- | | WebM (Video) | 24 KB | 94.6% | | AVIF (Image) | 35 KB | 92.1% | | MP4 (Video) | 63 KB | 85.9% | | WebP (Image) | 136 KB | 69.6% |
WebM (a video format) is the smallest, but there’s only partial support in Safari on iOS as of this writing. AVIF (an image format) is close behind, but it isn’t supported in Edge (or natively in content management systems like WordPress).
So sometimes a video is smallest, and sometimes an image is smallest. Most browsers don’t support video in img elements, so you have to choose one or the other. And the worse-case img scenario is significantly larger in file size, but the video version lacks straightforward preloading or alternative text. Good grief!
So, what should we use?It depends.
If you can’t change the markup for img elements, then WebP is the only format with universal support. You can plop one into an img element’s src attribute with no other changes and it’ll work just like a GIF in every modern browser:
<img src="clip.webp" alt="…" width="…" height="…">Code language: HTML, XML (xml)
If you can change the markup but you’d like to stick with the img element’s behavior, then a picture element with AVIF and WebP types is the way to go. You can even keep the GIF as a fallback:
<picture> <source type="image/avif" srcset="clip.avif"/> <source type="image/webp" srcset="clip.webp"/> <img src="clip.gif" alt="…" width="…" height="…"></picture>Code language: HTML, XML (xml)
But if file size is your biggest priority, then a video element with WebM and MP4 sources should generally yield the most savings (with less discrepancy between formats):
```
Code language: HTML, XML (xml) ``` You’ll also want to consider how the format you choose fits into your workflow. Can your team generate it? Will your content management system support it? Can you offload the transformation to the server or a third-party service?
Is there life after GIF?There’s significant work to be done if we want designers, developers and content authors to embrace these newer formats.
We need a comparable experience. If we can’t have video sources for img elements, fine, but the video element in its current state won’t cut it. We need a clear means of defining alternative text, we need preloading, we need easy saving and sharing.
We need better compatibility, and not just across browsers. MacOS won’t correctly preview animated AVIF, animated WebP or WebM. Adobe Media Encoder won’t do WebM, WebP or AVIF out of the box. I had to trick WordPress into letting me upload an AVIF file. It’s idealistic to expect a format to immediately leapfrog the GIF’s decades-long head start, but running a third-party plugin or cryptic Terminal command to generate files our environment won’t recognize is a really tough sell.
We need to talk about video and animated image formats in the same breath. Many articles compare video formats to GIF, or image formats to each other, but rarely are all possible formats discussed together. WebP is smaller than GIF, great, but why use that instead of WebM? AVIF is smaller than WebP, sure, but is it also smaller than existing video formats?
My gut says GIFs on the web are like customary units of measurement here in the United States: They’re so thoroughly entrenched that they’ll probably always be around. But just as the metric system slowly crept into our nutritional food labels and soda bottles, there’s opportunity for newer formats to gain a foothold in our process anyway.
Big thanks to my Cloud Four teammates Paul Hebert and Jason Grigsby for the inspiration and technical review. Thanks also to Chris Silverman, Dusty Pomerleau and Callie Riggins for sharing thoughts with me on Mastodon. Apologies to Eric Bailey for leaving out zoetropes.
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.See our work
We recently worked with Cloudinary to rebuild their blog. There was a big focus on performance throughout the process, especially passing Core Web Vitals. However, we recently started seeing poor Cumulative Layout Shift scores on a number of posts. I set out to investigate.
Cumulative Layout ShiftCumulative Layout Shift is a metric that tracks how much content on the page shifts while the user interacts with it. These layout shifts can be a real pain for people trying to use your site:
Have you ever been reading an article online when something suddenly changes on the page? Without warning, the text moves, and you’ve lost your place. Or even worse: you’re about to tap a link or a button, but in the instant before your finger lands—BOOM—the link moves, and you end up clicking something else!
web.dev
Our Real User Metrics showed that desktop users were often experiencing unacceptable levels of layout shifts.
Tracking Down Our Layout ShiftTo better understand the types of layout shifts our users were experiencing, I used the dev tools to throttle my connection and highlight layout shifts. With page loading slowed down, I could see and understand the layout shifts:
I could see that the overall layout was shifting during the page load. The width of the main post container shifted a couple of times, causing the whole page to re-render.
After some debugging, I realized a couple of seemingly unrelated choices were combining to trigger these layout shifts:
ch units to constrain our post content.Using ch unitsThe ch unit is equivalent to the width of the 0 character in the currently selected font. This makes it really helpful for typographic fine-tuning. For example, you can use it to apply a max-width to your prose content to cap the overall line length to improve readability.
A “Flash of Unstyled Text”When using a web font, the browser needs to decide what to do while the font is loading. By default, the browser will not display the relevant text until the font has loaded. This is called a “Flash of Invisible Text,” and it means that visitors can’t start reading your content until the web font has loaded, which can make the page feel slower.
Luckily, there are other options. We were optimizing for a “Flash of Unstyled Text.” This means that the browser immediately displays our text in a fallback font that’s already loaded on the visitor’s computer. When our font loads, it then gets swapped in to replace the fallback.
This ensures that visitors can start reading content sooner, but when the font swaps in, it can cause layout shifts. I thought this might be contributing to our Cumulative Layout Shift, but it didn’t account for the intensity of layout shifts we were seeing.
In our case, we were achieving this using async font loading, but it can also be achieved using font-display: swap.
Combining ch Units and a “Flash of Unstyled Text”In our case, we were using ch units to define our page layout. This meant that our post content was equal to 50ch units (or the width of fifty 0 characters all lined up in a row).
But the width of the 0 character differs from font to font. So when our web font was swapped, the width of 1ch changed, which impacted our layout:
ch is equal to the width of the 0 character in the fallback font.ch unit is equal to the width of the 0 character in our web font.ch units, and ch units base their size on the 0 character in the current font, the post layout shifts with the font change.Visualizing the ProblemHere’s a CodePen showing the issue. The font is swapped every two seconds. Since the post content is set using ch units, it changes too.
See the Pen ch and Font Layout Shifts by Paul Hebert (@phebert) on CodePen.A Quick FixTo fix the issue, I swapped out my ch units for rem units, which stay consistent regardless of font. This small change resolved our layout shift issue without affecting the overall layout.
We will also want to look into using tools like the Fallback Font Generator and CSS rules like size-adjust to adjust our fallback font to be sized more closely to our web font. But, for now, this quick fix avoids severe layout shifts for our users.
ch units are really neat! But watch out for layout shifts when using them.
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.See our work
I had the opportunity this week to talk to Nicole Mors, Product Design Manager at Driveway. We dove into the challenges of managing a living design system, what designers can do to improve accessibility, and whether designers should prioritize learning how to code.
I found the discussion about design systems to be particularly interesting. Managing and growing a mature design system within an organization is a different endeavor than when you first built one, and as Nicole mentions, it isn’t a technical challenge. It’s about people. Knowing when to diverge and keeping the priority of constituencies in mind can help make sure those people have what they need to move quickly and maintain UX consistency.
Transcript of our discussionMegan (00:03):
Hi, Nicole! Hey thanks for making time to talk with me today. Maybe we could start with you just talking about who you are and where you’re working and what your background is, and then we’ll dive in.
Nicole (00:18):
Yeah, yeah, yeah. I’m Nicole Mors. I am a designer. I’ve been a designer for a long time. I graduated from design school right into the recession, so that was fun times started out in print, so the little.
Megan (00:40):
Me too!
Nicole (00:41):
Yeah, the little print shop I worked for did not survive, and I had to quickly change gears. So I realized, you know, I’m gonna have to diversify, go digital, and I haven’t looked back since. I legit have not spent a day thinking about being a print designer again, ever. Spent a lot of time working at various little companies cutting my teeth, taught myself to code and went all the way down the rabbit hole with that. So I very much consider myself a technical designer. Spent some time on the agency side, actually working with Megan at Cloud Four. And then most recently I’ve been in product design, which is where I’ve kind of found my niche of sort of technical design and visual, just everything good about product design: UI, UX, you have everything. And most recently I work at Driveway where I am a Product Design Manager, so specifically focusing on the craft of people management and design leadership. And it’s been awesome. Driveway’s a car buying and selling platform where folks can purchase a car from anywhere and have the car delivered to them, and it’s been awesome, Our team is about 30 designers, and I manage personally a third of that.
Megan (02:17):
Can you talk to me a little bit about, you mentioned the, like coding as a designer thing. It’s a big thing at Cloud Four that we talk about a lot. Like our, our engineers are, have art backgrounds, our designers know how to code. And I wonder, like in the broader landscape, is that, is that still a thing that’s a priority? Like when you go to hire, are you looking for that? And then when you see people like designers out there, is this like a differentiating skill or is it something that kind of everybody does now?
Nicole (02:46):
Yeah. I feel like people have really passionate views on this, right? Like, this isn’t just like, yeah, oh, to code or not to code. I personally I’ve almost made it a part of the, my professional ethos that I know how to code. And so it’s hard for me to be like, oh yeah, designers, it doesn’t matter. I think if you are designing for the web, that is your canvas, that is your medium and you should know how it works. When I was building out my team, especially for design systems, I looked for designers who had front end chops. You don’t have to be a developer, but you have to know how designs are going to be realized in the browser in order to be able to design for it. So yeah, definitely feel pretty passionately about that, and I do think it is a differentiator and it certainly differentiated me in my career and the paths I took.
Megan (03:49):
Yeah, you know, I know you have a passion around accessibility, and so much of what makes a website accessible is very technical, right? We’re talking about like how the code is marked up and how, like whether an ARIA attribute is there or something. And I just, I wonder, if you don’t have that experience, how, how do you learn what’s okay from an accessibility standpoint or how to design for accessibility?
Nicole (04:17):
So I think, I think if you don’t know how to code designing for accessibility is theory based, right? And you’re looking at things that you have control and knowledge over, like color contrast, the sort of visual sides of accessibility.
Megan (04:34):
Yeah. So on the accessibility, kind of, train of thought, what are the things that designers can control and can influence because so many things are decided at the implementation stage?
Nicole (04:49):
Yeah, I think designers should have a perspective over, I think there’s a lot, you can still imply tab order without knowing how to code, how screen readers should read things, what alt text should be on images. And then the easy things, like I mentioned before, color contrast. They really can have opinions about all that and be able to communicate it to engineers. It shouldn’t just be left up to implementers, all those considerations. Designers should have equal stake in that. And, if the conversations are happening sooner than less, the, there’s less issues in the browser and the experience will just be better.
Megan (05:36):
Right. So you’re kind of moving the conversations earlier in the funnel.
Nicole (05:40):
Exactly. Exactly.
Megan (05:43):
You also, I think, have a pretty unique background with design systems, I know when you worked at Cloud Four, we sort of worked on building design systems for clients and now you’re on the product side of things where your job is maintaining the design system, right? And growing the design system. What particular challenges do you see on the day to day with, like, being the, I don’t know, are you like the ambassador of the design system?
Nicole (06:13):
Yes, 100%. I am the design system. And we have a team, we have a content designer on our design system team, a lead designer and a senior designer. So there’s about four of us and we currently don’t have any dedicated engineers. So we have like a sister team that we partner with on the engineering side. What I will say about like handing off a design system versus living with the design system is two completely different worlds. And, you know, the pattern libraries and design systems we handed off at Cloud Four were pristine, you know, very controlled, and you package it up, and you hand it to them and you’re like, here you go. You know, have fun with that! Obviously we were there for support, but it’s nowhere near the same as like being in the product on the day-to-day and implementing your own system and then maintaining it, advocating, answering questions just on the regular. I had so much angst thinking about the design systems I handed off and what people had to do to implement them, especially into existing products, like that is no simple situation. And there’s so much that you learn when you’re in a product implementing a system, so many concessions. You have to make so many things to think through that I was just like, oh my gosh, what did those clients, what did those people do? Do you know what I mean, are they ok?
Megan (07:57):
Yeah. I mean, it takes a life of its own. Right. And like, you hope that, like you’ve set things up in a way that like it can grow and live, but it is a growing and living thing. And what, like, are there particular challenges that you’ve had to overcome or that you’re currently working through that like you’re able to talk about? Cause I know, you know, obviously it’s work stuff. Yeah.
Nicole (08:21):
What current challenges are we facing right now? You know it’s hardly ever like design problems or code problems, it’s always culture and people problems, adoption problems, the business wanting to invest in the design system. It’s, those are the sort of issues that become, you know, the bigger, stickier issues versus just like, oh, the design of components, that stuff
Megan (08:56):
We need a new button. Yeah. .
Nicole (08:57):
Yeah. That stuff is, is pretty, you know, that’s like the regular stuff. That’s like, “keep the lights” on stuff. It’s the other bigger, meatier problems that that become harder. And it’s, it’s almost like politics. You kind of like go shake hands and kiss babies and talk about the design system and advocate for it with all the groups and all the teams. Yeah. So that’s like the work.
Megan (09:27):
Right, like, I mean, at the end of the day, it’s a tool that people use and so it’s people that you have to make sure understand the value of it, understand why they need to use it, you know? Yeah. Get, get on board so that they actually start using it. Things like things like that.
Nicole (09:46):
Yeah. And it’s, I’ve been at Driveway almost two years. We spun up the design system team right when I started. So there’s been a team in place. The system has grown, and to this day we still have to be like, “hey, use the design system in your project.” Like to this day, two years later, on a, you know, a relatively mature team, we still have to be like, “mm mm you know.”
Megan (10:16):
“Hey, hello, do you know we have a component for that?”
Nicole (10:18):
Exactly. What you’re doing over here looks very similar to what we’re doing over here. There’s a whole thing for that, you know? So, so it’s still, you know, still work in progress for sure,
Megan (10:34):
But do you see the, like the benefits of it, like the efficiencies that are gained when it, when it’s working the way that it’s supposed to?
Nicole (10:44):
Yeah, absolutely. Absolutely. we can move faster, you know, in the design process we have consistency. It’s, you know, our brand is supported in a way that makes sense throughout the product. When the design system’s utilized. There’s just, you know, the benefits are there for sure. Yeah, the benefits are there. It’s just…
Megan (11:13):
Like, do you think it’s a workflow thing? Like I’m trying to get this thing done, I can do it faster if I just do this really quickly, and then the design system wizard shows up and says, but hey, , think longer term, grab this component, everybody wins!
Nicole (11:32):
Yeah. I think it is a skillset. And like what, sort of, stage your designers are at? I think for Driveway, we were in sort of this startup stage where we were hiring a lot of agency designers, no offense been there myself. A lot of visual designers and everything was bespoke started from scratch, you know what I mean? So those folks who were in that timeframe are, are probably just like, I love pushing the pixels, I love creating the beautiful mockups of things as opposed to maybe a designer who’s more skilled in UX, who’s gonna be like, here’s my grab bag, here’s my toolkit. I’m gonna start just putting together flows and solutions utilizing the system, and I’m gonna work at it from that angle a, as opposed to a designer who’s like, I wanna create a beautiful mockup of a solution.
Megan (12:35):
That makes sense. Yeah.
Nicole (12:37):
Yeah. So I think it’s that, and it’s just like those different stages of maturity for a design team. And you need, you need both, but at different times. So now we’re moving on to like, we need UX, we need folks to utilize the patterns and components that we have. We need to move fast. We need to make sure the solution and the direction’s, right? We already have the visual design. We already have everything figured out. Just utilize, you know, what works and create the best experience for the customer. And, like pixel perfect, mockups are not like the main focus. It’s really like shipping, fast, shipping the right thing. Yeah.
Megan (13:22):
Yeah. Cool. That, those are the questions I had. Thanks for making time today. I really appreciate you always.
Nicole (13:29):
Totally.
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.See our work
Deep in the W3C HTML Design Principles spec, there’s a crucial detail that we at Cloud Four use as our north star in the course of our work. We’ve written about this before, specifically how it pertains to design systems. It’s called the Priority of Constituencies, and it’s described as follows:
In case of conflict, consider users over authors over implementors over specifiers over theoretical purity. In other words costs or difficulties to the user should be given more weight than costs to authors; which in turn should be given more weight than costs to implementors; which should be given more weight than costs to authors of the spec itself, which should be given more weight than those proposing changes for theoretical reasons alone. Of course, it is preferred to make things better for multiple constituencies at once.
I asked my friends on LinkedIn if they had ever heard of it. It’s a small sample set (I’m no influencer, I guess!), but a significant majority had not.
Poll from LinkedIn post and further proof that the author is not very popular on said platformThis is a shame! We should be shouting this from the rooftops. This little gem from the spec is super powerful. Then why is this not something everyone talks about? This should be our primary talking point when engaging with clients about design. They should walk away talking about the priority of constituencies and using it to help make decisions.
But they don’t. And I think it’s because this is written in the language of specifications, not humans. I’ll admit it hurts my brain a little to read all the way through it. So, I wanted to break this down into a more readable approach:
When you aren’t sure what to do, always prioritize end users first. Once the user’s needs are met, consider the authors next. When the author’s needs are met, you can consider developer needs.
Only after all those are considered should you worry about specification writers. Never prioritize theoretical purity unless all the other needs are met.
It’s always best to improve things for everyone if possible.
I might be putting a little bit more oomph behind the original words, but I think it’s warranted.
Also, because this is part of a W3C specification, it gives you backing when faced with resistance and you need to assert these priorities. If someone asked you to use a deprecated HTML tag, you’d balk, right? Send them a link to the spec with your explanation. Who can argue with the W3C?
Related: Priority of Constituencies at AEA 2021In his 2021 presentation, We’re All Writing Our Own Web Standards Now, Jason mentions how the Priority of Constituencies grounds him to what matters most (40:00). “Particularly when we’re tackling complex challenges or battling browser bugs, as web authors we’re working in the service of others.”
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.See our work
On a recent project, I finally found a solution to an issue I’ve run into several times: When listening for events in JavaScript, how can I tell whether an event was triggered directly by a user or by my code?
I was enhancing a video element to run a special action whenever a user played the video:
const myVideo = document.querySelector('#my-video');myVideo.addEventListener('play', () => { doSomething();});Code language: JavaScript (javascript)
This worked great! When a user played the video, my code was able to respond. I was ready to call it a day and go sit in the garden, but there were other enhancements we needed to make.
I needed to be able to programmatically play the video when a user performed certain actions. I wired that up:
function playVideo() { // If the video's already playing, do nothing if (!myVideo.paused) { return; } myVideo.play().catch((error) => console.warn(error));}Code language: JavaScript (javascript)
This also seemed to be working great! I got my sun hat to head outside… but then I realized there was an issue. When the play function was called, it triggered my event listener. But I only wanted to run my callback when the user manually played the video, not when my code triggered the play function.
How could I differentiate a user playing the video from my code calling the video play function?
Dead EndsI’d been here before and knew this was a tricky problem. JavaScript doesn’t provide an easy way to distinguish events that are triggered by a user or triggered by code. I started researching, chatting with colleagues, and experimenting. At first, all I found were dead ends.
If you’re not interested in the attempts that didn’t work, you can skip ahead to the working solution.
Trusted EventsThe first stop on my Dead End World Tour was the event.isTrusted property. MDN says the following about this property:
The
isTrustedread-only property of theEventinterface is a boolean value that istruewhen the event was generated by a user action, andfalsewhen the event was created or modified by a script or dispatched viaEventTarget.dispatchEvent().
This sounds like exactly what I needed! I can use it to tell if the event was generated by user action! But, my testing told another story… isTrusted was true whether a user pressed the “Play” button or my code ran myVideo.play()
Looking at the official spec made this clearer:
isTrustedis a convenience that indicates whether an event is dispatched by the user agent (as opposed to usingdispatchEvent()).
isTrusted is only false if the event was dispatched using dispatchEvent or a similar function.
Are there other event properties we could use?Some Stack Overflow posts suggested checking for special properties that would be present for user-triggered events. For example, pointer events have screenX and screenY properties that tell you where the click occurred. If those are both 0 you could be pretty sure that it wasn’t a user-triggered click event.
Unfortunately, I couldn’t find any similar properties to use for the play event.
Could we use a click event instead?This raised an obvious question. Could we hook into click events instead? This also didn’t work out. The video element embeds the browser’s video player widget, which captures click events which means they don’t bubble up to my event listener.
Could we build a custom video player?I mean… I guess…
In theory, we could have built our own custom video player UI and had greater control over the experience. But this would have greatly increased the development complexity, as well as requiring users to download more JavaScript. We’d also need to reproduce all of the browser’s functionality or risk introducing accessibility issues.
This might have been the right solution for another project, but it felt like overkill here.
The (hacky) solutionI finally found a Stack Overflow post by Ankit Chaudhary that pointed me in the right direction. There’s nothing built-in to JavaScript to help us know whether an event was triggered by a user, but we can add logic to keep track of that ourselves:
const myVideo = document.querySelector('#my-video');let videoPlayedByCode = false;function playVideo() { // If the video's already playing, do nothing if (!myVideo.paused) { return; } // Record that the video playing was triggered by code videoPlayedByCode = true; myVideo.play().catch((error) => console.warn(error));}myVideo.addEventListener('play', () => { // If this event was triggered by code, return early and don't // perform our actions. if (videoPlayedByCode) { // But make sure to reset this variable for the next // time the video plays. videoPlayedByCode = false; return; } doSomething();});Code language: JavaScript (javascript)
This can be a little confusing at first glance. Here are a couple of different scenarios and how this code would handle it.
A user-triggered event:
play event listener is triggered.videoPlayedByCode is false so our listener proceeds to respond to the user’s action.A code-triggered event:
playVideo() function.videoPlayedByCode to true and then plays the video.play event listener is triggered.videoPlayedByCode is true so our listener knows this wasn’t a user-triggered action.videoPlayedByCode is reset to false.Try playing the video below using the browser’s built-in play button and the custom play button to see how the demo responds.
See the Pen User vs. Code Events by Paul Hebert (@phebert) on CodePen.
As you can see, the browser doesn’t provide much help when differentiating user-triggered events and code-triggered events, but with a bit of custom JavaScript you can keep track yourself. I’ve run into this situation a few times and am happy to finally have a solution. I hope this helps you out if you run into a similar challenge.
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.See our work
It’s counterintuitive and misleading, but if you use YouTube’s no cookies domain, YouTube will still set cookies when someone starts playing a video.
I recently discovered this on a website where we can’t use a cookie consent banner. The best way to comply with privacy laws was to avoid cookies and personally identifiable information.
We removed Google Analytics. We didn’t need cookies for the custom functionality we built. The only third-party service we used was YouTube, and we were using the no cookie version of YouTube.
We thought we had succeeded in building a cookie-free site. On the contrary.
YouTube No Cookies Doesn’t ExistI have since learned that YouTube no cookies isn’t a real feature. Instead, it is called YouTube Enhanced Privacy Mode. The reason many people call it YouTube no cookies is because the way to turn on YouTube Enhanced Privacy mode is by switching the domain that you use to embed videos from:
www.youtube.comCode language: plaintext (plaintext)
to:
www.youtube-nocookie.comCode language: plaintext (plaintext)
You would be forgiven for thinking that a domain that says nocookie wouldn’t set a cookie, but that’s not what happens. Per Axbom describes how YouTube’s “Enhanced Privacy Mode” actually works:
- If you use the youtube-nocookie.com domain, there is no cookie set when the page with the YouTube embed loads.
- Instead, YouTube utilizes something called Local Storage in your browser to store a unique device identifier. Note that this is done without anyone’s consent and GDPR is violated already in this step. GDPR is not only about cookies.
- As soon as a user presses Play on the video, a cookie from YouTube is set. Whether or not consent has been given from the viewer. The second violation of informed consent in the same embed.
Given this behavior, naming the domain nocookie seems Orwellian.
Should I have known this?After I discovered that YouTube was setting cookies and reading Per’s excellent summary of the issue, I found news articles from 2009—shortly after the feature was released—that point out “YouTube’s new ‘nocookie’ feature continues to serve cookies.” It has been this way from the beginning.
But I’m not the only one who was fooled. It isn’t hard to find articles on privacy and GPDR compliance that advocate for using www.youtube-nocookie.com without mentioning that YouTube will still set cookies if you use that domain.
So I’m still kicking myself for not double-checking to make sure cookies weren’t getting set. Trust, but verify as it were.
But mostly, I’m mad at YouTube. It can’t be a surprise that many people thought this feature wouldn’t set cookies. It’s right there in the domain name.
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.See our work
I consider myself a fairly capable developer. I even enjoy working with the infrastructure that powers our projects. I love setting up our design tokens, preprocessors, linters, and other tools that help us write better code. That said, there’s one thing that causes me to break into a sweat: configuring complex build tools like Webpack and Babel.
These tools, while undeniably powerful, are some of the most arcane and difficult to work with I’ve ever used. I’m sure there are people out there who are rolling their eyes at me, and find this stuff completely understandable. I’m happy for you! But I don’t think I’m alone in feeling this way.
Let me share an example Webpack config from a Nuxt project we maintain (feel free to just skim past this, I’m just making a point about complexity):
/* nuxt.config.js */module.exports = { /* ** You can extend webpack config here */ extend(config, { isDev, isClient, loaders: { vue } }) { /** * Transpile All Node Modules */ const jsRule = config.module.rules.find((rule) => rule.test.test('.js')); // don't transpile babel helpers and core-js jsRule.exclude = /(core-js|babel)/; const babelOptions = jsRule.use[0].options; if (isClient) { // By default, babel will assume all modules are ES modules. This would // lead babel to inject ES imports even in commonjs files. // Source Type unambiguous forces babel to check each file individually // and decide whether it is commonjs or an ES module. babelOptions.sourceType = 'unambiguous'; } /** * Allow vue-loader to transform assets in `data-srcset` and `data-src` * as well as `srcset` and `src`. * * @see https://dev.to/ignore_you/minify-generate-webp-and-lazyload-images-in-your-vue-nuxt-application-1ilm */ if (isClient) { vue.transformAssetUrls.img = ['data-src', 'src']; vue.transformAssetUrls.source = ['data-srcset', 'srcset']; } /** * Allow Inline SVGs * * Nuxt has a single rule for all image types that uses `file-loader`. * This rule says "For SVG images with the inline parameter, * use `vue-svg-loader` instead." * * @see https://vue-svg-loader.js.org/faq.html#how-to-use-both-inline-and-external-svgs */ const svgRule = config.module.rules.find((rule) => rule.test.test('.svg') ); svgRule.test = /\.(png|jpe?g|gif|webp)$/; config.module.rules.push({ test: /\.svg$/, oneOf: [ { resourceQuery: /inline/, use: [ // babel loader is run after the svg files are transpiled into vue // components (webpack runs loaders bottom-to-top) { loader: 'babel-loader', options: babelOptions, }, { loader: 'vue-svg-loader', options: { svgo: false, }, }, ], }, { loader: 'file-loader', options: { esModule: false, name: 'assets/[name].[hash:8].[ext]', }, }, ], }); /** * Run ESLint on save */ if (isDev && isClient) { // eslint-disable-next-line global-require const ESLintPlugin = require('eslint-webpack-plugin'); config.plugins.push( new ESLintPlugin({ extensions: ['js', 'vue'], }) ); } }, },};Code language: JavaScript (javascript)
Believe it or not, that’s a relatively simple and well-documented config. We only needed to add a few things, and the devs who added them left helpful comments and links to documentation. Still, when something goes wrong? It’s a nightmare trying to figure out why and how to fix it.
And that’s why I’ve been so thrilled with Vite (pronounced “veet,” French for “quick”), a modern dev environment and build tool that completely replaces Webpack. I could bore you with details like how it takes advantage of browser-native JavaScript modules to support dependency pre-bundling and hot-module replacement, or how it was originally created to speed up Vue, but has been converted to a framework-agnostic tool, or that in just two years it’s grown to over 3 million downloads per week. But frankly, you’d be better served checking out Vite’s features page.
What I want to rave about is what I consider the best feature of Vite. The thing that’s had the most dramatic impact on the way I work, and why it’s so useful to me. I want to talk about Vite’s simplicity.
Remember that “simple” Webpack config? Here’s the Vite config from the same project after we upgraded:
/* nuxt.config.js */import svgLoader from 'vite-svg-loader';export default defineNuxtConfig({ vite: { plugins: [svgLoader({ svgo: false })], },});Code language: JavaScript (javascript)
That’s it! That’s the whole thing! All the same features, but with only a single line of config to load a plugin to inline SVGs.
Compared to Webpack, Vite is delightfully easy to use. As an opinionated tool, it simply handles most of the things we need right out of the box. Your config file is likely to be minimal. In many cases, it’s only used to load plugins that help Vite understand how to process things like Vue single-file components or inlining SVGs. On several of my simpler side projects, there’s no config file at all!
At the most basic level, Vite only cares about your entry file — the index.html file that lives at the root of your app. Any CSS or JS files you load from there will be processed by Vite.
*.ts extension, and Vue will handle everything for you.To do all these things in older projects using Webpack and Babel required a nightmare of configuration, plugins, and maintenance.
There are a lot of technical reasons why Vite is great. But for me, it removes the single most painful part of modern web development. At the end of the day, it just works. With very little instruction, it does everything I want. Load this file, process it as needed, and let me get back to writing code.
Thanks, Vite!
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.See our work
Intrigued and uneasy. That’s how I would describe my feelings about the latest wave of AI tools that seem to have appeared overnight. ChatGPT was the first tool I played around with, and I immediately saw its utility. I could certainly envision using it to help spark inspiration or clear any writer’s block I might’ve been struggling with. It’s an excellent idea generator or can kick off your writing in a way that a blank notepad document does not.
Sometimes, I welcome our robot overlords. Last week, I was reminded about how early this technology is.
I was scrolling my LinkedIn feed, and I came across a post by Alex Boyd, founder of RevenueZen, that a connection of mine had commented on. In that post, Alex says they got their first lead from ChatGPT. My mind spun a bit – a client lead? How in the world did that happen?
It turns out they aren’t totally sure either, but a potential client asked ChatGPT a question that led them to Alex’s company, and they reached out to connect.
As a small business owner, this piqued my interest. At Cloud Four, we’ve been adjusting to the world since 2020 and what it means for our business development efforts. How do we reach potential customers when our usual avenues have changed significantly? So, the idea that AI might help in that regard was interesting.
I was able to get ChatGPT to list potential agencies to contact for our niche (which, you may be surprised to know is not AI, but instead responsive web development and progressive web apps), but it tries hard not to make recommendations (“As an AI language model, I do not have personal recommendations…”)
That was an interesting endeavor, but the most fascinating part happened when I asked ChatGPT to tell me what it knows about my company, Cloud Four.
Cloud Four is a web development and design agency based in Portland, Oregon, USA. The company was founded in 2007 and has a team of experienced developers and designers who specialize in building responsive websites, mobile apps, and progressive web apps (PWAs).
Cloud Four is known for its expertise in web performance, accessibility, and user experience (UX) design. They have worked with a wide range of clients, from startups to Fortune 500 companies, across various industries, including healthcare, finance, and education.
So far, so good. You are really nailing it, robot!
The agency has received several awards and recognition for its work, including the 2018 Net Awards Agency of the Year and the 2019 Awwwards Agency of the Year.
Wait, what? Does ChatGPT know something I don’t? We don’t typically submit for awards like this (although our clients sometimes do).
It turns out that part was wildly inaccurate. I’m not even sure where that information came from. But, it shows both the power and the challenge of AI content generation. You have to be really careful, and it’s easy to be fooled. This feels like a step in the wrong direction in a world teeming with misinformation.
So, for now, while it’s all very exciting and interesting, I’ll be proceeding with caution. The robot overlords won’t take me yet.
(And I did not use ChatGPT to write this article, but I did use Grammarly to help edit it. Thanks, robots!)
We’re Cloud FourWe solve complex responsive web design and development challenges for ecommerce, healthcare, fashion, B2B, SaaS, and nonprofit organizations.See our work
Our team works on a lot of ecommerce, event and marketing projects where the creative team wants big, expressive and impactful headlines for campaigns and promotions.
But if the container resizes, the content is changed, translated or localized, or if the user customizes their zoom or text size, we can end up with typographic orphans: A tiny extra word or two, awkwardly tucked away to one side.
That may not seem like a big deal, but small chunks of text are easier for readers to miss. Imagine your favorite quote or catchphrase missing its last word (“a long time ago in a galaxy far, far”) and you’ll understand why designers and marketers sweat these details.
I’ve encountered a lot of different hacks to address this over the years:
span with display: inline-block to exert some control over line breaks. to encourage hyphenation of longer words.br elements at different breakpoints.But none of these are fool-proof, and all have significant shortcomings (issues with dynamic content, fallback fonts, performance, entity encoding, etc.).
So I was ecstatic when I saw Chrome’s intent to ship CSS headline balancing, nearly a decade after it was first proposed.
As I’m writing this article, you can use text-wrap: balance in Chrome Canary (with Experimental Web Platform features on) and it… just works?! Notice how the text distributes itself more evenly between lines:
Una Kravets shared a great demo of this in action.
I really hope we see positive signals from the Gecko and WebKit teams soon. Let’s spend less time troubleshooting line breaks, more time styling text!
With the news that CSS Container Queries have shipped in nearly all stable, modern browsers, it’s time to revisit responsive images and ask how they fit in a container query world.
Why do we need Container Queries?We’ve been building responsive web designs since 2010 without container queries, why do we need them now? The truth is we don’t need them now—we needed them several years ago!
Responsive web design relied on media queries because that’s what we had available to us. Media queries make decisions based on the size of the viewport. And that’s fine when you’re designing the full page layouts. But media queries have always felt like a hack when you’re designing discrete sections within a page.
For example, if we’re designing a product tile for Walmart Grocery, we know how the tile should respond to the space allotted to it, but we don’t know in advance how that allotment will relate to the size of the viewport. And the more work we do with design systems and component-based development, the more likely we are to run into cases where we’re designing to the size of the container, not the size of the viewport.
Components using Container Queries are PowerfulMax Böck created my favorite example of what container queries can enable in this demo of bookstore interface. The demo combines container queries with web components to create a compelling experience shown in this video below.
This fictional bookstore web site has three areas. There is one book in the featured section. It is larger and has a 3D spine. There are six books in the bestseller section with medium-sized images of book covers. And there is one book in the cart where a smaller thumbnail is used. The video shows how books transform as they are dragged from section to section.
You can also play with the demo on CodePen. I’ve embedded the pen below, but I recommend playing with it outside of the constraints of an embed. Special thanks to Max Böck for allowing me to use the video and CodePen in this article.
See the Pen Container Query Bookstore by Max Böck (@mxbck) on CodePen.
Each of the books in the demo is a web component. The web component has its own rules and behaves differently depending on the size of its container. Here is what the CSS for the container queries looks like:
`/* Small Variant: Simple Cover + Title */@container (max-width: 199px) { .book { padding: 0; }}/* Medium Variant: Multi-Column, with Author */@container (min-width: 200px) and (max-width: 399px) { .book { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }}/* Large Variant: 3D Perspective */@container (min-width: 400px) { .book { position: relative; transform-style: preserve-3d; transform: rotateY(-25deg); }}`Code language: CSS (css)
These three container queries cover the different ways the book component is used in the page. You can drag and drop books from section to section in the page and see how they transform in appearance. All of this functionality and styling happens automatically when someone adds the <book-element> component.
Container queries are amazing. We can build a component for others to use without knowing in advance where they will use it. Nor do we need to know the relationship between the viewport and the component in order to make the component responsive.
But what do we do with the responsive images in this example?
Responsive Images Conflict with Container QueriesFor Max’s bookstore, ideally we’d base the size of the images and their sources on the size of the container. The size of the container is what determines if the image will be large with 3D perspective, or if it is going to be thumbnail.
Unfortunately, responsive images syntax is based on the size of the viewport. In fact, the sizes attribute uses a subset of media queries, called media conditions, to tell the browser the size of the image at different viewport sizes. For example, the syntax for an image using srcset and sizes might look like:
`<img src="cat.jpg" alt="cat" srcset="cat-320.jpg 320w, cat-640.jpg 640w, cat-1280.jpg 1280w" sizes="(max-width: 480px) 100vw, (max-width: 900px) 33vw, 254px">`Code language: Handlebars (handlebars)
If the same person creating the book component is also building the page, then perhaps they know where the images will be used and could update the component to provide the necessary connection between the size of the image and the viewport.
But there’s no guarantee the same person who creates a component is going to be the one implementing it in a page. The person using a component could work in another part of the same company—this is common for companies with design system teams.
Or they may work in different companies altogether. Shopify recently released a series of Commerce Components for use by Shopify customers. The people at Shopify who built these components have no idea how these components will be used on client sites.
We lose a lot of the power of container queries if the moment a component has an image, we have to revert back to viewport-based media queries for responsive image syntax.
Why not use container queries for responsive images?It is natural to think that the problem with images in container queries is a mere oversight. We used to only have media queries to design with. Now we have container queries. Images use media conditions are a subset of media queries. Ergo, we need a subset of container queries for images. Let’s call them container conditions and use those in our sizes attributes. Done. Ship it!
If only it were that simple.
To understand why it is difficult, we have to revisit the core challenge that led to the responsive images standard in the first place: the browser’s speculative downloading of images.
Speculative DownloadingWhen the browser first receives an HTML document, before it builds the DOM, and long before it calculates layout, a feature called the lookahead pre-parser scans the document looking for assets it can start downloading. This behavior is called speculative downloading because the browser can’t be certain that the assets that it downloads will be used.
But getting a head start on downloading assets, even if some items are downloaded by mistake, has a significant impact in web page performance. Andy Davies reports, “During their implementation Mozilla reported a 19% improvement in load times, and in a test against the Alexa top 2,000 sites Google found around a 20% improvement.” And in 2015, Ilya Grigorik found that ”~43% of image fetches are initiated by the speculative HTML scanner, which account for ~50% of transferred bytes.”
The speculative downloader has been an undeniable boon for web performance. Unfortunately, the speculative downloader is in intractable conflict with responsive design.
In a responsive web design, the layout and images are all fluid. The size of any given image cannot be determined until the page layout is calculated by the rendering engine. And that’s far too late for the speculative downloader which starts downloading images immediately.
That’s why the sizes attribute was created in the first place. It was a compromise between fluid images in a responsive design and the speculative downloader. It tells the browser the image size at different viewport widths. And because the browser always knows the viewport width, it can calculate the image size needed immediately and speculative downloader can start retrieving the best-sized image from the list of sources in the srcset.
Container Queries and the Speculative Downloader Don’t Get AlongUnfortunately, component authors using container queries don’t know the size of the viewport—all they know is the size of the container they are designing for. Sara Soueidan explored some of these challenges in an article on Component-level art direction with CSS Container Queries. And Una Kravets opened an issue for this problem in the CSS Working Group two years ago. The ticket spurred a lot of discussion, but no agreed upon solution.
One possible solution that Yoav Weiss suggested is something akin to nested sizes attributes. In this scenario, there would be a sizes attribute on the container that tied the width of the container to the viewport. Then images inside the container would have their own sizes attributes based on the container’s size. It might look something like this:
`<container viewport-sizes=" (max-width: 480px) 100vw, (max-width: 900px) 33vw, 254px"> <img src="cat.jpg" alt="cat" srcset="cat-320.jpg 320w, cat-640.jpg 640w, cat-1280.jpg 1280w" container-sizes=" (max-width: 900px) 100cqw, 254px"></container>`Code language: Handlebars (handlebars)
Both viewport-sizes and container-sizes are attributes I made up. I’m certain Yoav would propose something more elegant for the actual syntax. But these two fictional attributes help illustrate that the “sizes” attributes for the container would be mapping to the viewport width and that the “sizes” attribute for the image would map to the container width.
In theory, this would work. In reality, I’m not so confident.
First, we know that the person authoring a component that uses a container query isn’t likely to be the same person using the component in the page. Only the person putting the component in a page can possibly know the viewport size.
Teaching someone how to calculate what to put in the sizes attribute on a container can be difficult. Component authors often struggle with adoption—it is consistently one of the top issues reported on Sparkbox’s Design System survey—so the last thing they need is to ask component users to figure out viewport widths.
Second, we know that developers often set the sizes attribute incorrectly. The HTTP Archive 2022 Web Almanac notes:
We estimate that one-quarter of desktop pages are loading more than 83 KB of extra image data, based purely on bad
sizesattributes. That is to say: A better, smaller resource is there for the picking in thesrcset, but because thesizesattribute is so erroneous, the browser doesn’t pick it. Additionally, 10% of desktop pages that use sizes load more than a half-megabyte of excess image data because of badsizesattributes!
There are proposals in the Web Hypertext Application Technology Working Group (WHATWG) to use auto sizes for lazy-loaded images as a way to simplify the use of responsive images and increase the chances that responsive images syntax is used correctly.
Given these facts, it seems a stretch to think that we’ll have greater success with nested sizes attributes.
Can Container Queries Ignore the Speculative Downloader?Maybe if you’re using container queries, you shouldn’t worry about the speculative downloader because using the container query means that you’re explicitly deferring any layout decisions until the size of the container is known. Maybe it is better to lazy load all images in container queries?
Unfortunately, ignoring the speculative downloader isn’t a great option. Take Max’s bookstore example. The book element is being used for several images that would be seen above the fold. Making the browser wait to download those images would have a significant impact on Largest Contentful Paint measures and thus slow down our user experience.
Plus, saying we can only use images in container queries below the fold where they can be safely lazy-loaded would be a pretty harsh restriction on our shiny, new container query toy.
Revisiting Responsive Images AssumptionsLong before we settled on responsive images syntax, many of our discussions would inevitably turn to the idea of a magical image format that would contain all of the resolutions we required. It isn’t as far fetched as it sounds. At the time, JPEG-2000 had the ability to “display images at different resolutions and sizes from the same image file.” As I wrote back then:
It seems that no matter where you’d like to see responsive images go—
srcset,picture, whatever—that everyone agrees we’d all be happier with a new, magical image format.
But the dreams of a holy grail image format went nowhere. It was said that JPEG-2000 was patent-encumbered, and we didn’t have any other formats on the horizon that offered similar advantages.
Not only that, but having a magical image format wouldn’t solve the problems with the speculative downloader:
Without the image breakpoints and without knowing the size of the image in the page, how would the browser know when to stop downloading the image?
Unless I’m missing something, it wouldn’t. The browser would start downloading the image file and would only stop once the layout had been determined. In the meantime, it may download a lot more data for a given image than is necessary.
Back then, this seemed like an unacceptable tradeoff for an image format that didn’t even exist. We were building responsive web designs right then. We needed a solution that worked with existing image formats, not something that would only work with a mythical image format and even then, might download extra data.
Over a decade later, I’m not certain we’d evaluate these tradeoffs the same way. We know how difficult it is to set up responsive images correctly. We have evidence that many websites are already downloading extra data because of mistakes in their srcset and sizes attributes. And that’s before we get to the challenges with container queries.
Given a decade of hindsight and anticipating our upcoming container query future, this no longer sounds so bad:
The browser would start downloading the image file and would only stop once the layout had been determined. In the meantime, it may download a lot more data for a given image than is necessary.
In fact, it sounds pretty idyllic.
JPEG-XL: The Holy Grail Image Format?I don’t know if JPEG-XL is the magical image format we’ve been looking for this whole time. What I do know is that the possibilities are enticing.
JPEG-XL was created with responsive design in mind. Jon Sneyers, one of the creators of the image format, describes it thusly:
Especially for web delivery, it would be desirable to avoid having to store and serve multiple variants of the same image according to the viewer’s viewport width. Equally desirable is an option to progressively decode images, showing a low-quality image placeholder when only a few hundred bytes have arrived and adding more detail as the rest of the data shows up. JPEG XL ably supports both nice-to-haves.
JPEG-XL has none of the royalty issues of JPEG-2000. It is designed to be friendly to older versions of JPEG because you can “transcode existing JPEG files effectively and reversibly to JPEG XL without any additional loss.”
There are still things we’d need to figure out. Eric Portis told me that “Browsers still don’t have a great mechanism to partially load ‘just enough’ of the file in a performant way, when knowing what ‘just enough’ is, is layout-dependent.”
But even in that area, we’re better off than we previously were. A decade ago, we were still using HTTP/1.1 where we opened up and tore down HTTP connections. With HTTP/2 and HTTP/3, we’re now reusing connections which will help reduce the expense of progressively downloading images.
All of this is why Google’s decision to drop support for JPEG-XL in Chrome is so disappointing. Among other reasons, Google says they dropped support because:
- There is not enough interest from the entire ecosystem to continue experimenting with JPEG XL
- The new image format does not bring sufficient incremental benefits over existing formats to warrant enabling it by default
I doubt many developers knew that JPEG-XL was in Chrome. I try to keep on top of responsive images news, and I only found out about that it had been implemented in Chrome when the news broke that it had been removed.
As for JPEG-XL providing sufficient incremental benefit over other formats, Jon Sneyers provides a long list of things JPEG-XL can do that other image formats cannot. But from my point of view, what matters most is that JPEG-XL was the only image format on the horizon that might be able to get us out of the mess of responsive image syntax.
Just when container queries are making it clear that we need to revisit our assumptions in order to support the future of responsive web design, JPEG-XL was removed. This seems like a mistake.
Is it time to fix our responsive images hack?Responsive images syntax always felt like a bit of a hack to me. I don’t mean that in a derogatory way. Hacks can be elegant solutions to difficult problems.
In this case, that’s exactly what we did. We knew that the sizes attribute brought presentation information—the width of the image at various viewport sizes—into HTML where it didn’t belong. But that was an acceptable tradeoff to support responsive images and the browser’s speculative downloader. And I’m proud of the work we did in the Responsive Images Community Group to define the standard and convince browsers to support it.
But even at the time, I had some misgivings:
In the long run—if we find our holy grail—this conflict [between responsive images and the speculative downloader] is likely to resurface which makes me wonder about our current efforts.
I whole-heartedly agree with Steve Souders that “speculative downloading is one of the most important performance improvements from browsers,” and until a new image format materializes, it seems we should do everything we can to accommodate the pre-parser.
And at the same time, I can’t help but wonder, if we all want this magical image format, and if in some ways it seems inevitable, then are we jumping through hoops to save browser behavior that won’t work in the long run regardless?
As we embark again on trying to figure out how to solve the riddle of supporting designs that want to be fluid—that respond to the size of their container—while continuing to support the browser’s speculative downloader, perhaps we should take a moment to ask if we’re on the right path.
Or whether we’d be better off trying to find our elusive holy grail image format and solving this problem in a more sustainable way.
When designing or building software, give careful thought to default values because people will end up using them.
A couple of years ago, my relationship with Lisa was getting pretty serious. Naturally, this meant we bought a fancy coffee pot.
My favorite feature of the coffee pot is its “Delayed Brew” setting. After filling the pot with coffee and water, you can press the “Delayed Brew” button to instruct the pot to automatically brew you a delicious batch of coffee first thing in the morning while you’re still asleep.
To me, this is the height of luxury. Every morning I wake up to the delicious smell of fresh-brewed coffee and have a few minutes to enjoy a steaming-hot mug before walking the dog and getting ready for work. Well, most mornings…
The TragedyRecently I woke up, walked into the kitchen, and poured myself a mug. I sleepily took a swig of the delicious brew — blegh! Cold, tepid coffee! Disgusting!
In my sleepy state, I’d missed all the warning signs: there was no fresh coffee smell, and the coffee pot was cold to the touch.
What went wrong?The previous day our power had temporarily gone out, and the coffee pot had reset. While preparing my “Delayed Brew”, I’d noticed the clock blinking and reset it to the current time. What I hadn’t realized was that the “Delayed Brew” time had also reset to its default time: midnight!
So at midnight, our coffee pot had helpfully brewed a big, fresh pot of coffee. Coffee that then sat out for seven hours until I woke up.
Really? Midnight?I’m not upset that the “Delayed Brew” time had reset. I get it. The clock doesn’t have a battery, and it shut down when the power went out.
I was a little disappointed that the default was midnight, though. My uninformed hunch is that this is not a popular time to drink coffee. 5 AM, 6 AM, 7 AM, and 8 AM all seem like more reasonable defaults. Reasonable defaults that would have left me with a chance for fresh coffee.
Defaults are importantAt the end of the day, this wasn’t a big deal. But it got me thinking about the default settings that I add when building software. Every time I add a default, I’m making a decision for other people, and it’s worth taking some time to think through the implications of that decision.
It’s easy to assume that folks won’t use the defaults, but some will (intentionally or accidentally.) By improving these defaults, you improve the default experience of your product.
If I ever design a coffee pot, I’m setting the default “Delayed Brew” time to 6 AM.
We recently made some changes to our site, but there’s a navigation effect we’re fond of that stuck around.
When you hover over a link on larger screens, all the adjacent links fade to a lower opacity:
A cursor hovers over each link in our site’s menu. As soon as the cursor enters, all links except the one being hovered fade out just a little.
We didn’t invent this effect (it’s a vintage CSS trick), but we’re asked about it often enough that I thought I’d share how it works.
Here’s the relevant CSS:
.nav:hover .nav-item:not(:hover) { opacity: 0.65;}
The selector does the heavy lifting here: When any part of the navigation is hovered, we lower the opacity of any navigation items that aren’t being hovered.
That’s really all there is to it! CSS is so great.
Dave Rupert shared his CSS wish list for the new(ish) year and said he’d love to see the same from others. Challenge accepted, friend!
:has won’t feel 100% real to me until Firefox supports them by default.display: contents but without the accessibility issues.@property. I want to animate custom properties, and not just in Chrome/Edge!Also…
(Looks back nervously over both shoulders)
…I don’t care if we ever get native CSS nesting.
(Runs away)
But I’m not sure when this became widely supported, and I can’t find anything about it online…
Recently I was working on an animated, circular progress meter:
See the Pen Circular Progress Meter by Paul Hebert (@phebert) on CodePen.In order for the stroke animation to work, I needed to do some fancy CSS calculations based on the circle’s radius.
The specific CSS isn’t important, but it’s kinda fun, so if you’re curious, check it out!
////// 1. We do some math to determine our circle's circumference. /// This gives us the length of the stroke on our circle/// 2. With more math we can determine how much of our circle's /// stroke should be left undrawn (e.g. if our value is 75%, /// 25% of the stroke should be undrawn. We need this in pixels.)/// 3. Use SVG stroke drawing to draw the visible part of our stroke/// @see https://css-tricks.com/svg-line-animation-works//// 4. By default, a circle's stroke starts on its right edge. /// We want it to start from the top so we rotate the circle./// 5. Animate the stroke animation///.circle-meter__circle { --radius: 47px; // 1 --pi: 3.14; // 1 - Close enough for our use case! --circumference: calc(var(--radius) * 2 * var(--pi)); // 1 --stroke-length: var(--circumference); // 1 --stroke-offset: calc( var(--circumference) - (var(--circumference) * var(--percent) / 100) ); // 2 rotate: -90deg; // 4 stroke-dasharray: var(--stroke-length); // 3 stroke-dashoffset: var(--stroke-offset); // 3 transform-origin: center; // 4}@media (prefers-reduced-motion: no-preference) { .circle-meter__circle { animation: stroke 750ms both; // 5 }}@keyframes stroke { from { stroke-dashoffset: var(--stroke-length); } to { stroke-dashoffset: var(--stroke-offset); }}
The important bit is that I needed the --radius property in my CSS to stay in sync with the radius set in my SVG code. But having this value in two different places across two different files made me feel a little itchy. If someone changed the radius later, they’d need to change it in both places, and if they didn’t, it would subtly break the experience.
SVG properties in CSSI knew that you could set some SVG properties in CSS (stroke, fill, stroke-width, and stroke-linecap to name a few.) This made me wonder if I could set the radius using the r property in CSS. So, I tried it out… and it seemed to work! Well, at least in Firefox.
With a bit of trepidation, I started testing in our other supported browsers. It worked in Chrome. It worked in Edge. It even worked in Safari! (And luckily I didn’t have to worry about Internet Explorer.)
When did this happen?I was a little surprised that this worked. My impression was that setting r from CSS was part of the SVG2 draft, but I didn’t think that was supported by any browsers, and VS Code doesn’t seem to like it. But it turns out that setting r is supported in all the browsers I care about?
I knew my colleague Tyler is always down to chat SVGs, so I mentioned it to him and he was also unaware that this was supported. Tyler and I tried to find mention of this new feature on caniuse, MDN, and various browser bug trackers but came up empty.
This leaves me with a lot of questions about what from the SVG2 spec is supported in modern browsers and no clear path to getting answers besides a lot of trial and error.
But, for now, I’m just happy that I can set r from CSS.
An Event Apart 2015 and 2016 lunch pails featuring illustrations of Jeffrey Zeldman and Eric Meyer in the style of the Flintstones and Blues Brothers.After seventeen years, An Event Apart (AEA) is no more. I know all things must end, but this one hit me hard. It’s been a couple of weeks, and I’m still thinking about what AEA meant to me, and how much I will miss it.
By the time I presented at my first AEA in 2013, I had several years of public speaking experience. Still, An Event Apart made me nervous. I was keenly aware that speaking at AEA was a privilege. The people who spoke at the conference were the best. The attendees were fabulous.
This was the big leagues. I was afraid I would screw it up.
My first talk at AEA documented by Jeffrey Zeldman, used under Creative Commons license.And I did. Sort of. The feedback was the audience enjoyed my talk, but people didn’t feel like it was applicable to their day-to-day jobs. I worried I wouldn’t get invited back.
But, I was already scheduled to give the same talk in another city. I took the feedback to heart and improved the presentation. I remember Jeffrey Zeldman giving a standing ovation after the second talk. My hard work paid off.
Rehearsing while blindfolded.I spoke at An Event Apart every year since 2013. It was always a privilege to be invited. I never took it for granted.
Over the last decade, I experienced things I never anticipated both on and off the AEA stage. Every year, I pushed myself to do something new in my talks—which is how I ended up in a used tuxedo, performing magic tricks to illustrate web form UX.
I fondly remember visiting the Smithsonian’s African History Museum with Eric Meyer; eating Salt Lick BBQ with Luke Wroblewski, Josh Clark, and Derek Featherstone; seeing Hamilton with Mini Markham, Val Head, and Dave Rupert; and many late nights chatting with attendees and speakers alike. The adventures are too numerous to list.
Smithsonian National Museum of African American History & Culture with the Washington Monument peeking over.Hamilton sign outside the CIBC Theatre in Chicago.I brought our oldest child with me on two trips. They listened to the talks and interacted with other speakers. Last night, they told me they want to study computer science in college. I have no doubt that An Event Apart played a significant role in that direction. They frequently asked when they could attend AEA again. Telling them that AEA is over was difficult.
My oldest child and I at An Event Apart Chicago, 2018. Photo by Jen Robbins. An Event Apart became my family away from home. I eagerly looked forward to seeing what other speakers would be speaking at the same event. And I knew I was guaranteed to see my friends behind the scenes, Toby, Marci, Stephen, Mike, and of course, Eric and Jeffrey.
That’s the part I’m going to miss the most. Even if I was never invited to be a speaker again, I knew I could buy a ticket, hop on a plane, watch mind-blowing talks, and see some of the people I cherish most. I don’t know when that will happen again, and that depresses me.
But at the same time, I am so grateful for the fond memories, the opportunities, and the dear friends that An Event Apart brought into my life. It was truly a one-of-a-kind conference, and I want to thank Jeffrey, Eric, and everyone involved for being a big part of my life for nearly a decade.
Who’s up for a reunion?
After a nice tromp through the snow last Sunday, I started to wonder if I could procedurally generate random snowflakes. After a little while coding by the window with a hot mug of coffee, I was pretty pleased with the results:
See the Pen Animated Snowflake (Generative) by Paul Hebert (@phebert) on CodePen.In this article, I’ll walk you through hand-coding an SVG snowflake, let you customize your own snowflake in an interactive playground, and show how a dash of JavaScript can help you generate infinite variations.
The Beauty of SymmetryOne of the things that make snowflakes so beautiful is their symmetry. Each snowflake is composed of several symmetrical “trees” that are rotated around a circle. Knowing this, we can draw a snowflake in three steps:
See the Pen Generative Snowflakes (Steps) by Paul Hebert (@phebert) on CodePen.1. Draw half a tree. 2. Copy the half tree to make a full tree. 3. Copy and rotate the tree around a center point several times.
These three steps can be repeated to generate a nearly infinite number of unique snowflakes:
See the Pen Animated Snowflake () by Paul Hebert (@phebert) on CodePen.Hand-coding a snowflakeBefore generating random snowflakes, we need to understand the code used to draw a snowflake. There are a few different ways to code graphics on the web. For our snowflakes, we’ll be using SVG since the syntax is similar to HTML, and they can be styled with CSS.
Setting up our canvasFirst, we need to set up a canvas for our drawing. In our case, this is an SVG wrapper element:
<svg viewBox="0 0 100 100" width="100" height="100" role="img"> <title>A Snowflake</title> <g class="snowflake" <!-- Our graphics code goes here --> </g></svg>
This SVG will house all of our graphics. There are a few things to note:
viewBox describes the coordinate grid for our graphic. Everything we draw will be drawn on a 100-unit square grid.role="img" tells browsers to treat the entire SVG as a single image instead of exposing each inner element to assistive technologies.<title> element describes the image to assistive technologies and search engines.<g> (group) element. (This isn’t necessary, but will make it easier to dynamically update our snowflakes later.)While we’re at it, let’s give our SVG a background color with some CSS:
/* Give our SVG wrapper a blue background */svg { background-color: hsl(200, 50%, 50%);}
Drawing half a “tree”For our first step, we need to draw a handful of lines. Luckily SVG has a <line> element that does just what we need:
<line x1="50" y1="50" x2="50" y2="10" class="trunk" />
The <line> attribute draws a line between two points along an x/y grid. The first point is defined by the x1 and y1 attributes. The second point is defined by the x2 and y2 attributes.
The line above will work for the “trunk” of our tree. It goes from the center of the grid (50/50), straight up to 10 units below the top of our grid (50,10).
Note: SVG strokes are centered along their paths, so this stroke will be perfectly centered. The stroke will be painted between 49.5 and 50.5 on our horizontal grid axis.
Next, we need to add some “branches.” These will start touching our “trunk” and then branch up and to the left:
```
``
You’ll notice that each of our branches starts touching our branch (x1="50"`) and then moves up and to the left.
We’ll also add a few CSS rules to style our lines:
line { /* Make all of our snowflake lines white */ stroke: #fff; /* Round our lines' endpoints */ stroke-linecap: round;}
Alright, we’re getting somewhere! We’ve got our “half tree.” Try tweaking the sliders below to see how a different trunk length or branch settings affects our snowflake and our SVG code:
See the Pen Half Tree by Paul Hebert (@phebert) on CodePen.Completing our “tree”Now we’ve got half a tree, but we need to add the other half. There are a few ways we could do this:
But, both of these options would lead to a lot of long and repetitive code, which could be hard to maintain. Luckily, there’s an SVG <use> element that allows us to clone and tweak chunks of SVG code. Here’s an example of how we can reuse our half-tree:
<g id="branches"> <!-- Move our branch `<line>` elements inside of a group so we can reference them together. --></g><use href="#branches" class="flipped-branches"/>
Note how the branches group and use element are linked by the branches ID. We’ve added a class to our <use> element so we can add styles to the copied branches. We’ll use the CSS scale property to flip our branches horizontally.
The scale property allows us to stretch and squish an element. scale takes two values: a horizontal scale value and a vertical scale value. One funny aspect of scale is that if you scale an element to a negative value, it will be flipped instead of squished. We can use -1 1 to flip our cloned branches:
.flipped-branches { scale: -1 1; transform-origin: center;}
You may have noticed we also set a transform-origin property in addition to scale. This tells our branches to flip relative to the center of our SVG container.
Try using the “Branch Translation” slider below and see how it affects the flipped branches.
See the Pen Ful Tree by Paul Hebert (@phebert) on CodePen.Copying and rotating our “tree”Now we’ve got one of our “trees” coded, but to get that fun snowflake effect, we’ll need to copy it several times and rotate each copy around a center point like the spokes on a bike wheel. Our friend <use> will help us streamline this:
<g id="tree"> <g id="branches"> <!-- Our branches go here --> </g> <use href="#branches" class="flipped-branches" /></g><!-- Copy our tree --><use href="#tree" style="--index: 1;" class="rotated-tree" /><use href="#tree" style="--index: 2;" class="rotated-tree" /><use href="#tree" style="--index: 3;" class="rotated-tree" /><use href="#tree" style="--index: 4;" class="rotated-tree" /><use href="#tree" style="--index: 5;" class="rotated-tree" />
You can see we’re using the same strategy we used to copy our branches, but this time we’re copying the entire tree five times. You may have also noticed that we’re giving each copy of the tree an --index custom property. We can use this custom property in our CSS to give each copy a different rotation around our center point:
.rotated-tree { rotate: calc(60deg * var(--index)); transform-origin: center;}
Since we have six trees (our original tree and five copies), we need to rotate each copy by 60 degrees (360 degrees divided by 6 trees) to space them evenly around our wheel.
If we had a different number of trees, we’d need to use a different number than 60 degrees. We can use another custom property and a little math to determine the degrees of rotation dynamically:
<g style="--tree-count: 6" class="snowflake"> <!-- Our trees go here --></g>
rotate: calc( 360deg / var(--tree-count) * var(--index));
Try changing the number of trees below and see how it affects the snowflake’s shape
See the Pen Full Tree by Paul Hebert (@phebert) on CodePen.Making your code configurable (a.k.a the snowflake building machine)In the demo above, we’ve got an array of input parameters that are processed and turned into an SVG string. That sounds an awful lot like a JavaScript function! Let’s turn our hand-written SVG into a function that accepts a settings object and spits out a snowflake.
This will allow us to generate random settings and procedurally generate snowflakes. (This logic is also what helped to power the demo above!)
// Our snowflake settings.// We could randomly generate these, or pull them from a form.const settings = { trunkLength: 40, branches: [ { distance: 10, length: 5 }, { distance: 20, length: 10 }, { distance: 30, length: 8 }, ], treeCount: 6}// Call our function and use it to // populate an SVG groupsvgEl.innerHTML = buildSnowflake(settings);// Our main function! // Returns the full snowflake SVG codefunction buildSnowflake({trunkLength, branches, treeCount}) { return ` <g class="snowflake" style="--tree-count: ${treeCount};"> ${buildTree({trunkLength, branches})} ${buildTreeCopies(treeCount)} </g> `}// A helper function that returns an // SVG group containing a // single "tree"function buildTree({trunkLength, branches}) { const trunk = ` <line x1="50" y1="50" x2="50" y2="${50 - trunkLength}" /> `; const branchStrings = branches.map(({distance, length}) => { const startY = 50 - distance; return ` <line x1="50" y1="${startY}" x2="${50 - length}" y2="${startY - length}" /> ` }); return `<g id="tree"> ${trunk} <g id="branches">${branchStrings.join(' ')}</g> <use href="#branches" class="flipped-branches" /> </g>`;}// A helper function that returns a // number of `<use>` elements // copying our "tree"function buildTreeCopies(treeCount) { let copies = ''; for(let i = 0; i < treeCount; i++) { copies += ` <use href="#tree" style="--index: ${i}" class="rotated-tree" />` } return copies;}
Try editing the settings in the CodePen below to see how it changes the output of our function and the shape of our snowflake:
See the Pen Untitled by Paul Hebert (@phebert) on CodePen.Introducing randomness with JavaScriptNow we’ve got a handy little function for generating snowflakes. In order to make this function “generative,” we’ll need to write some logic for generating a random settings object to pass into our buildSnowflake function.
We’ll use a small JS helper function called randomInt to generate random integers between two values. (I won’t dive deep into how that works here, but if you’re curious, you can view how it works in the upcoming CodePen.)
Here’s an example of how we could generate a random settings object:
function randomSettings() { // Create a random trunk length const trunkLength = randomInt(1, 50); // Create an array of branches // Each branch will have a random distance and length const branchCount = randomInt(1, 10); const branches = []; for(let i = 0; i < branchCount; i++) { branches.push({ distance: randomInt(1, 40), length: randomInt(1, 30) }); } // Determine how many trees/spokes // to show const treeCount = randomInt(2, 30); return { trunkLength, branches, treeCount }}// Pass our random setting into our // snowflake function svgEl.innerHTML = drawSnowflake( randomSettings());
This makes some interesting shapes, but they don’t always feel likes snowflakes. Try clicking the “New Snowflake” button a few times below to see the generated shapes:
See the Pen Snowflake (JS Example) by Paul Hebert (@phebert) on CodePen.Sometimes the “trunk” lines are too short. Sometimes there aren’t enough “trees.” Sometimes the branches feel misaligned or extend outside our canvas. Sometimes it feels more like a doily…
Some of these patterns are really cool, but if we want to make snowflakes, we’ve got some more work to do…
Introducing Constraints (a.k.a making the snowflakes more snowflakey)First off, let’s ensure the “trunks” of our trees are a reasonable length. We’ll set a more reasonable minimum and maximum:
const trunkLength = randomInt(20, 40);
The next part is a bit trickier. There are a couple of issues with our current branches:
We can make a few tweaks to fix these:
// Instead of randomly generating our // branches, we'll start near our // center point and move outwards, // adding branches until we extend // past our trunk length.for ( let distance = randomInt(6, 10); distance < trunkLength; distance += randomInt(2, 10)) { branches.push({ distance, // When we get towards the end of // our trunk, constrain the branch // length so they don't extend too far length: randomInt( 5, Math.min( trunkLength - distance, 10 ) ) });}
We’ll also want to set a minimum and maximum number of “trees” that feels more like a snowflake:
const treeCount = randomInt(5, 12);
While we’re at it, let’s randomize one more piece of our snowflake. Let’s generate a random hsl color and use it to set our page’s background color:
const color = `hsl( ${random(190, 210)}, ${random(30, 60)}%, ${random(50, 80)}%)`;/* ... meanwhile, in our `drawSnowflake` function... */document.body.style.backgroundColor = color;
Let’s see how this is working:
See the Pen Snowflake (JS Example – Randomized) by Paul Hebert (@phebert) on CodePen.These feel a bit more snowflakey to me. We’ve turned our random pattern generator into a random snowflake generator!
Make it your ownThere are lots of different snowflakes and a lot of different directions we could take it from here:
I hope this taught you a little bit about SVGs, JavaScript, and generative art. I’d love to see what you make with these techniques.
If you remix the demos above or make brand-new generative art, post it in the comments!
During our 2022 redesign, Tyler noticed that our OG (Open Graph) tags weren’t working quite right. We had been using Jetpack to add these, but for reasons that weren’t clear to us, that stopped working in late 2020. In the interim, we tried a few alternative plugins, but none of them worked quite the way we wanted.
Here’s the great thing about the Open Graph protocol: It uses native HTML elements! There’s nothing particularly complex about the tags themselves, there are just a lot of them, and it was convenient to have a plugin generate them for us. But that meant we were giving up a degree of control, and accepting what the plugin thought was the correct output. Since our site has several custom content types and some special logic for handling the featured image, we decided to roll our own.
If you’re interested in doing the same, I hope this is helpful. I’ll be broadly summarizing what we did in this article, but if you use WordPress, you may be interested in viewing our OG helper code directly.
Adding an Open Graph helper functionStep one was adding a new PHP file containing a script that will generate the OG tags. We already have a collection of these, which we store in a /helpers directory. I created add_open_graph_tags.php there, and stubbed out an empty add_open_graph_tags() function.
```
'og:site_name', 'content' => get_bloginfo('name')], ['property' => 'og:locale', 'content' => get_locale()],];// Echo the OG tags to the pageforeach ($open_graph_tags as $tag) { echo sprintf( "\n", $tag['property'], $tag['content'] );} ``` And with that, suddenly two OG tags are being rendered on every page of the site! Homepage OG tagsFrom here, the bulk of the function is broken up by WordPress content type, using the handy `is_type()` helpers. For example, here’s what the code to add homepage-specific OG tags looks like: ``` // Homepage OG tagsif (is_front_page()) { $open_graph_tags = array_merge($open_graph_tags, [ ['property' => 'og:type', 'content' => 'website'], ['property' => 'og:url', 'content' => get_bloginfo('url')], ['property' => 'og:title', 'content' => get_bloginfo('name')], [ 'property' => 'og:description', 'content' => get_bloginfo('description'), ], [ 'name' => 'description', 'content' => get_bloginfo('description'), ], ]);} ``` The whole thing is nested inside an `is_front_page()` check, so we know this code will only run on the homepage. You may have noticed that all the items have a `property` key except the second `description` item. That’s because we want to generate both an OG description tag and a traditional `` tag. In theory, you can skip `og:title` and `og:description` and sites that consume your OG tags *should* fall back to the `"Learn the rules like a pro so you can break them like an artist."
— Falsely attributed to Pablo Picasso
As soon as I read my teammate Paul’s explanation of the math behind nesting rounded corners, I wanted to recreate it using custom properties and calc.
When I was starting out as a web designer, few experiences inspired me as much as Gorillaz’ official website in the early-to-mid aughts.
I was recently designing an interface with a lot of rounded corners. But, when I nested rounded corners it looked off somehow...
How to create a complex but highly customizable background gradient that can be modified easily using CSS custom properties.
In the waning days of Summer 2007, four colleagues decided it was time to do our own thing. We weren’t sure exactly what we’d do, but thought it might involve the possibilities and promise of the mobile web… which thankfully turned out to be kind of a big deal. That year was a whirlwind of […]
Using the native HTML disclosure widget for a burger menu is so enticing. Unfortunately, the details/summary elements come with accessibility issues, so it's not an inclusive solution.
I knew CSS blend modes could create some cool effects, but even so, a CodePen I saw recently left me shocked at what they’re capable of.
"Swoop-and-poop" refers to when you're nearing the end of a project or task, and at the last minute, an important decision-maker swoops in and lets you know that you're on the wrong track.
Learning VoiceOver can feel overwhelming, so I’m here to give you a simple, repeatable process you can follow to make testing with VoiceOver as easy as possible.
We just shipped the largest update to cloudfour.com since 2016! This time around, we had three main goals…
Font subsetting allows you to split a font's characters (letters, numbers, symbols, etc.) into separate files so your visitors only download what they need. There are two main subsetting strategies that have different advantages depending on the type of site you're building.
Components are everywhere, but they are rarely reusable across systems. A design system component is written differently than a CMS editor component. But does it have to be this way? Could we take one set of components and port them to multiple JavaScript frameworks, import them into design tools, and use them for the editing interfaces in content management systems?
I’ve spent years looking for tools that help designers who don’t code participate in a process like the one we use. Something that would let them reuse design system components and would allow them to do as Stephen Hay says and resize their designs until they break and then… BOOM… they need a breakpoint. Unfortunately, […]
Responsive design sprints are a significantly better way to design and build for today's web than the traditional web design process. We provide the receipts. Unfortunately, not every organization can adopt this responsive design sprints. Why is that and what can be done about it?
Web design software makers saw the pain caused by the design to developer hand off and built features to help. Unfortunately, these features don’t help as much as the software makers hope. At best, they are unwanted features to be ignored. At worst, they reinforce faulty assumptions that undermine design systems.
On a recent client project, we built a form that submitted to a third-party registration service. Easy-peasy, right? What followed was a comical series of incidents that served as an excellent lesson in defensive API handling.
The traditional web design process hopes that static mockups—representing mobile, tablet, and desktop breakpoints—provide developers with everything they need to know to turn the designs into functional web pages. In reality, design happens between breakpoints.
Responsive design broke the traditional web design and development process in fundamental ways. Despite this fact, many organizations continue to use this broken process.
By wrapping and enhancing HTML elements, we can provide a solid baseline experience, with progressive enhancement as the cherry on top.
These days, the arguments for a baseline font size of 16 pixels are widely accepted. But there are plenty of reasons to go even larger!
For a recent project, we needed to take a small web application and embed it inside a client’s existing site. Typically, this means inheriting the site’s styles. However, in this case, the client wanted this app to follow a new design system that hadn’t been applied to the site yet. That raised some issues for […]
We’re thrilled to announce that we’ve added accessibility tree snapshots to Pleasantest. These snapshots incorporate important accessibility details into your tests, helping you to understand, track, and maintain the accessibility of your interfaces. We believe Pleasantest is the first testing tool to provide this incredibly useful feature.
In Cloud Four’s core values, we state our belief in “the web as a unifying platform to provide access to information for all people of all abilities.” This inspires us to follow best practices (writing semantic HTML, for example), but more importantly, it reminds us to build empathy for experiences that may be different from […]
A friend recently shared his frustration with CSS development. I responded to him with a high-level overview of the current state of CSS. If you’re feeling a bit out of touch with modern CSS development, I hope this helps. You’d be surprised how much you can do with vanilla CSS nowadays!
When I started giving talks about SVG back in 2016, I'd occasionally hear a question I never had a great answer for: What if you have a lot of icons on a page?
Of all the things that the W3C has published, my favorite is the priority of constituencies. That’s quite a statement given the W3C published the standards that form the foundation of the web and, by extension, my career. But the priority of constituencies has always deeply resonated with me. What happens if we apply it to design systems?
In the first part of this series we created a program to generate unique solar systems by drawing and animating SVG circles:
See the Pen Generative SVG Solar Systems: Step 5 by Paul Hebert (@phebert) on CodePen.
But the solar systems are composed of solid colors and feel a little flat. We can make them feel more lifelike and fun by using some SVG magic. Here’s what we’re working towards.
See the Pen Generative SVG Solar Systems: Step 6 by Paul Hebert (@phebert) on CodePen.
Click “Refresh” to generate new solar systems SVG Filters (a.k.a. Avoiding Complicated Math) I spent some time researching how to generate different textures. Unfortunately, most of the solutions I found included doing a bunch of complicated math that I’d rather not do. Luckily, I found an excellent article by Sara Soueidan, showing how to use SVG filters to create textures. With a few lines of code, we can have the browser generate unique textures for us!
Styling Planets
We can use SVG filters to add textures to our planets. Filters are created as SVG elements, which you can then reference via ID to style other elements using the filter attribute:
```
```
A Turbulent World
First off we’ll create some “turbulence” using the <feTurbulence> filter. Turbulence in this context refers to randomly generated “noise” or textures. The filter has four different attributes we’ll be using. (Don’t worry, these will make a lot more sense when we get to the demo.)
baseFrequency: This attribute determines how tall and wide our noise is.numOctaves: This attribute determines the level of detail in our noise. Higher numbers look more natural, but are more computationally expensive.type: Setting the type to fractalNoise will produce a smoother noise with less sharp edgesseed: The seed is used as a starting point for the random noise. Using unique seeds will ensure you get unique noise.By default our filters will extend outside of the planet we’re applying them to. Adding an <feComposite> filter layer will allow us to constrain the filters to our circle:
```
```
Here’s a demo showing turbulence applied to our planet circle. Adjust the turbulence properties to see how they affect our texture:
See the Pen A Turbulent World by Paul Hebert (@phebert) on CodePen.
These textures are interesting, but they’re not quite what we’re looking for. In Sara’s article she showed how you can layer a lighting filter on top of turbulence to create more realistic textures.
Shining Some Light Lighting filters treat two dimensional graphics as three dimensional graphics and simulate shining a light on them. We can add a lighting layer to our filter to create a more lifelike texture.
There are a few different types of lighting filters. For our use case we’re going to apply <feDiffuseLighting> on top of our turbulence. It has two attributes we’re interested in:
lighting-color: Determines the color of light to apply (and therefore the color of the generated texture)surfaceScale: Determines the “height” of the surface the light is applied to. The higher the value, the more contrast will show in the generated texture.Lighting filters are meant to wrap light sources. In our case we’ll be using <feDistantLight> which has a couple properties we’ll play with:
elevation: Determines the height the light shines from (how high in the sky the light is located.) This is represented as an angle between 0 and 360.azimuth: Determines the direction the light shines from (0 to 360).We’ll add these new filter layers to our existing filter. (Note that the lighting filters still come before the feComposite filter so that the lighting is limited to our planet’s shape.)
```
```
This is all a bit tricky to understand, but playing with a demo makes it clearer how the different filter attributes interact:
See the Pen SVG Turbulence, Lighting, and Composite Example by Paul Hebert (@phebert) on CodePen.
Now we’re getting somewhere! By tweaking the attribute values we’re able to generate some fun textures for our planets. Let’s update our solar system planets to use these effects.
Applying Our Texture Filters To ensure our planets are unique and different we’ll use randomization to select values for our turbulence and lighting. But we want to make sure our values make pleasing textures.
In order to do so we’ll need to put some constraints on the values we generate. We can use our randomization functions to pick values from predefined ranges and update our drawPlanet() function from part 1:
``
// We added a new count parameter we'll use below.
function drawPlanet(size, distance, count) {
const hue = randomInt(0, 360);
const saturation = randomInt(70, 100);
const lightness = randomInt(50, 70);
const color =hsl(${hue}, ${saturation}%, ${lightness}%)`;
const cx = width/2 + distance;
const cy = height/2;
// We'll use the current planet number to create unique IDs
// for our filters
const id = planet-${count};
// We'll generate some random values for our turbulence const turbulenceType = randomBool() ? 'fractalNoise' : 'turbulence'; // We intentionally make the y value larger than the x value // to create horizontal striping patterns const baseFrequencyX = random(0.5, 2) / size; const baseFrequencyY = random(2, 4) / size; const numOctaves = randomInt(3, 10); const seed = Math.random();
// And some random values for our lighting const elevation = randomInt(30, 100); const surfaceScale = randomInt(5, 10);
// We'll use those random values to create our filter:
const filter = <filter id="${id}-texture">
<feTurbulence
type="${turbulenceType}"
baseFrequency="${baseFrequencyX} ${baseFrequencyY}"
seed="${seed}"
numOctaves="${numOctaves}"
/>
<feDiffuseLighting lighting-color="${color}" surfaceScale="${surfaceScale}">
<feDistantLight elevation="${elevation}" />
</feDiffuseLighting>
<feComposite operator="in" in2="SourceGraphic"/>
</filter>;
// And apply the filter to our planet:
const planet = <circle
class="planet"
style="
--start-rotation:${randomInt(0, 360)}deg;
--rotation-speed:${distance * randomInt(40, 70)}ms;
"
r="${size}"
cx="${cx}"
cy="${cy}"
fill="#000"
filter="url(#${id}-texture)"
/>;
return filter + planet; }
```
Let’s see it in action:
See the Pen Generative SVG Solar Systems: Step 8 by Paul Hebert (@phebert) on CodePen.
Click “Refresh” to generate new solar systems and planets. It’s fun to see these textures on our planets, but they still don’t really feel three dimensional. Let’s add a shadow to them to simulate the “dark side” of planets facing away from the central star.
The Dark Side To add our shadows we’ll be using two more SVG tools: radial gradients and clip paths.
We can use a <radialGradient> to create a smooth transition between two colors. For our shadow we’ll transition from complete transparency (hsla(0, 0%, 0%, 0)) to complete black (hsla(0, 0%, 0%, 1)):
```
```
See the Pen Generative SVG Solar Systems: Step 8 by Paul Hebert (@phebert) on CodePen.
This is close to what we want, but our shadow is extending past the edge of our planet. We can add a <clipPath> to clip the shadow to our planet. Similar to filters and gradients, we’ll first define our <clipPath> and then reference it by ID:
```
```
See the Pen The Dark Side (Clipped) by Paul Hebert (@phebert) on CodePen.
We can take it a step further, and give our planets a highlight by adding a white circle shifted one pixel to the left:
```
```
See the Pen The Dark Side (Clipped) by Paul Hebert (@phebert) on CodePen.
This looks a little extreme, but will work better in the context of our solar system
These shadows are looking pretty good! Let’s update our drawPlanet() function. Since we’re now showing multiple circles, we’ll need to put them into a group and move our planet class and custom properties to the group so they all orbit together:
``
const planet =
<g
class="planet"
style="
--start-rotation:${randomInt(0, 360)}deg;
--rotation-speed:${distance * randomInt(40, 70)}ms;
"
<circle r="${size}" cx="${cx - 1}" cy="${cy}" fill="#fff" /> <circle r="${size}" cx="${cx}" cy="${cy}" filter="url(#${id}-texture)" /> <circle cx="${cx - size}" cy="${cy}" r="${size * 2}" fill="url(#${id}-shadow)" clip-path="url(#${id}-shadow-clip-path)" />`;
```
Here we can see it in the context of our larger solar system:
See the Pen Generative SVG Solar Systems: Step 8 by Paul Hebert (@phebert) on CodePen.
These planets are looking great! Let’s apply some similar effects to our stars!
Styling Stars
A lot of the effects we’ll apply to stars use the same techniques we used for planets, so I won’t do a deep dive, but I want to highlight one more SVG filter that will come in handy: <feGaussianBlur>.
This aptly named filter allows you to blur a graphic. The level of blurriness is set using the stdDeviation attribute. This will come in really handy for giving our stars “glowing” effects.
```
```
See the Pen The Dark Side (Highlighted) by Paul Hebert (@phebert) on CodePen.
Use the slider to adjust the blur level In order to build our stars we’ll use a combination of blurs, lighting and turbulence. In total we’ll use 5 different layers of circles:
I’m not going to go through the entire filter code here because it got quite lengthy, but you can check it out, and view how the different layers interact in the CodePen below:
See the Pen Generative SVG Solar Systems: Step 7 by Paul Hebert (@phebert) on CodePen.
On the left you can see each layer individually. On the right you can see them combined into a finished star. Click “Refresh” to generate new stars.
We’ll update our drawStar() function from part 1 to output these circles in our solar systems:
See the Pen Generative SVG Solar Systems: Step 9 by Paul Hebert (@phebert) on CodePen.
Setting up Star Fields Now we’re getting somewhere! We’ve got generative stars and planets styled with some awesome SVG filters. But the background’s still a little plain. Let’s add a starry sky!
Again, we’ll be stacking multiple layers of graphics to get the effect we’re going for:
Here are the layers we’ll be rendering for our background. Again the individual layers are on the left, and they’re stacked on the right:
See the Pen Generative Planet Filter Effects by Paul Hebert (@phebert) on CodePen.
Now we can plug this in to our draw() function and complete our generative solar system!
``` function draw() { let starSize = randomInt(70, 120); let markup = drawStarField() + drawStar(starSize) + addPlanets(starSize);
document.querySelector(".js-svg-wrapper").innerHTML = markup; }
```
See the Pen Generative SVG Solar Systems: Complete by Paul Hebert (@phebert) on CodePen.
Awesome, we've made a generate solar system art piece! Spam that “Refresh” button to generate new solar systems! Next Steps I’m really pleased with the generative solar systems we’ve built but there are still lots of opportunities for improvement. We could add moons or rings to our planets, improve our filters, or add asteroid belts. (If you make an improvement, I’d love to see it! Please share it in the comments.)
We’ve also learned a lot of new skills. We’ve got a framework for creating generative art, and we’ve learned a ton about SVGs, CSS and JavaScript which we can apply to other areas of web design and development.
If you’re curious, you can check out more of my generative art at squigglesanddots.art.
Lately I’ve been having lots of fun creating procedurally generated artwork. These art pieces are drawn by a computer following a series of predefined steps and making random choices along the way. My favorite piece so far generates solar systems:
See the Pen Generative SVG Solar Systems: Step 6 by Paul Hebert (@phebert) on CodePen.
Click “Refresh” to generate new solar systems I learned a ton about JavaScript, SVGs, CSS (and space!) while making this. It’s way too much to fit into a single article so in this post will focus on generating and animating solar systems using SVGs, JavaScript, randomness, and CSS.
By the end of this section, we’ll have built the following generative art piece. (Part 2 will finish the solar system shown above.)
See the Pen Generative SVG Solar Systems: Step 5 by Paul Hebert (@phebert) on CodePen.
Click “Refresh” to generate new solar systems
All of my generative art pieces share three building blocks: JavaScript randomness functions, an SVG wrapper, and a draw() function.
JavaScript Randomness functions The fun of procedurally generated artwork comes from mixing things up. By having the program make random choices we can make each art piece unique and different.
JavaScript exposes a Math.random() function that will return a pseudo-random number between 0 and 1. We can use this to build a few other randomness helper functions. None of these are perfectly random, but they’re close enough for our use case:
``` // Return a number between two values. function random(min, max) { const difference = max - min; return min + difference * Math.random(); }
// Returns a random integer between two values function randomInt(min, max) { return Math.round(random(min, max)); }
// Returns true or false. By default the chance is 50/50 but you // can pass in a custom probability between 0 and 1. (Higher // values are more likely to return true.) function randomBool(probability = 0.5) { return Math.random() > probability; }
// Returns a random item from an array function randomItemInArray(array) { return array[randomInt(0, array.length - 1)]; }
```
With these helpers in place we’ll be able to introduce randomness so that each solar system we generate is unique. Next up, we need an SVG wrapper for our solar system. 1
SVG Wrapper An SVG element will contain all of the graphics that make up our solar systems:
``` <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200" viewBox="0 0 1000 1000" style="background: #000;" role="img" aria-labelledby="solarSystemTitle" aria-describedby="solarSystemDescription"
A procedurally generated solar system A 2D rendering of a solar system, with planets orbiting a central star.
```
There are a few things to note about this SVG:
viewBox attribute defines our SVG coordinate system. In this case we’re saying that all of our graphics will be placed relative to a 1000 pixel square grid.role="img" and an accessible name and description using aria-labelledby and aria-describedby. This will give screen reader users information about the graphic we’re creating. (Though writing good descriptions for generative art can be very tricky!).js-svg-wrapper group element. This will be the element we insert content into.<style> tag. We’ll be adding some global CSS there later.A draw() Function
Next we need to populate our SVG. To do so, we’ll create a draw() function which sets the innerHTML of our wrapper element. For now, we’ll place a randomly sized circle at the center of our SVG to represent our solar system’s star:
``` // Define a couple variables about our SVG grid const width = 1000; const height = 1000;
// Our draw function is where the ✨ magic ✨ happens function draw() { let starSize = randomInt(70, 120); let markup = drawStar(starSize);
document.querySelector(".js-svg-wrapper").innerHTML = markup; }
// We create a separate drawStar function to make it easier
// to change later
function drawStar(size) {
// cx and cy represent the x and y coordinates for the
// center of our circle.
// r sets the radius of the circle. We'll use our star size.
// For now we'll make our star white (#fff).
// We'll change the color soon.
return <circle
cx="${width / 2}"
cy="${height / 2}"
r="${size}"
fill="#fff"
/>;
}
```
We’ll hook up this function to run when the script first loads, and re-run when a “Refresh” button is clicked:
``` draw();
const refreshButton = document.querySelector(".js-refresh-button") refreshButton.addEventListener("click", draw);
```
See the Pen by Paul Hebert (@phebert) on CodePen.
We’ve now got a generative art piece! (Just not a very exciting one.) Click the refresh button to randomly resize the star in our SVG.
Expanding Our draw() Function
We’ve now got all of our boilerplate in place. From here on out, we’ll be making changes to our draw() function.
Adding Planets First off, let’s add some planets. It’s not much of a solar system if it’s just one star.
Let’s add a couple functions to draw planets and their orbit paths. For now our planets are plain white, but we’ll add colors soon.
``
function drawPlanet(size, distance) {
// We center the planet vertically, but we adjust the x
// position by our orbit distance. Theplanetclass
// will be used to set up our planet orbit CSS
return
function drawOrbit(distance) {
// The orbit is centered and has a radius equal to our
// current distance
return <circle
cx="${width / 2}"
cy="${height / 2}"
r="${distance}"
stroke="#ccc"
fill="none"
/>;
}
```
Now we need to call these functions. We’ll create a new addPlanets() function and call it from our main draw() function:
``` let markup = drawStar() + addPlanets(starSize);
```
We’ll use a while loop in our addPlanets() function to keep adding planets until we’re getting to the edge of our canvas:
``` // Define some helper functions to randomize plant size // and orbit distance let randomPlanetSize = () => randomInt(10, 50); let randomOrbitDistance = () => randomInt(100, 120);
function addPlanets(starSize) { let markup = "";
// Set up our first planet let planetSize = randomPlanetSize(); let orbitDistance = starSize + randomOrbitDistance();
// Keep adding planets until a planet's orbital distance and // size would lead to it extending past our canvas while (orbitDistance + planetSize < 500) { // Add our new planet and its orbit path to our markup markup += drawOrbit(orbitDistance); markup += drawPlanet(planetSize, orbitDistance);
// Prep our next planet so the while loop can check
// whether it's in bounds
planetSize = randomPlanetSize();
orbitDistance += randomOrbitDistance();
}
return markup; }
```
Now we’re getting somewhere! We’ve got a central star with planets placed on orbit paths around it! Go ahead and click that “Refresh” button a few times and watch the scene re-draw itself.
See the Pen Generative SVG Solar Systems: Step 2 by Paul Hebert (@phebert) on CodePen.
Animating our Orbits
This is still a little boring. It would be nice to get the planets to rotate around the star. Luckily, we can use CSS transforms and keyframe animations to get this working! Let’s go back and add some CSS to the <style> tag inside of our main SVG markup.
``` / Storing values as custom properties will make them easier to change later / :root { --start-rotation: 0deg; --rotation-speed: 10s; }
/ Set up an animation to rotate from 0 to 360 degrees / @keyframes orbit { from { transform: rotate(var(--start-rotation)); } to { transform: rotate(calc(var(--start-rotation) + 360deg)); } }
.planet { / Apply our animation to the planets / animation: orbit var(--rotation-speed) infinite linear; / Within an SVG, the transform-origin is set relative to the SVG. This ensures our orbit will rotate around the center of our star / transform-origin: 50% 50%; }
```
See the Pen Generative SVG Solar Systems: Step 2 by Paul Hebert (@phebert) on CodePen.
This is closer but it’s still not quite right. All of the planets are orbiting around at the same speed and rotation. Ideally each planet would have its own speed and rotation. Since our CSS is already using custom properties, we can update our drawPlanet() function to set unique values for those custom properties. We can incorporate the distance into our random rotation speed to make further orbits take longer.
``
function drawPlanet(size, distance) {
return
```
Now each planet has a randomized starting rotation and rotation speed:
See the Pen Generative SVG Solar Systems: Step 4 by Paul Hebert (@phebert) on CodePen.
But these planets still feel a little plain. Let’s add some colors!
Adding colors
For generative art I really like using HSL colors. Since they allow us to separately set the hue, saturation, and lightness of a color, they make it easy to randomly generate colors within specific parameters. Let’s update our drawPlanet() function to use a randomized color for its fill:
``
function drawPlanet(size, distance) {
const hue = randomInt(0, 360);
const saturation = randomInt(70, 100);
const lightness = randomInt(50, 70);
const color =hsl(${hue}, ${saturation}%, ${lightness}%)`;
return <circle
cx="${width / 2 + distance}"
cy="${height / 2}"
r="${size}"
fill="${color}"
class="planet"
style="
--start-rotation:${randomInt(0, 360)}deg;
--rotation-speed:${distance * randomInt(40, 70)}ms;
"
/>;
}
```
Note that hue is a value between 0 and 360, while saturation and lightness are both percentages (and require percentage signs.) We’re picking a random hue from 0 to 360, while limiting saturation and lightness to predefined ranges. Here’s what that looks like:
See the Pen Generative SVG Solar Systems: Step 4 by Paul Hebert (@phebert) on CodePen.
Next up, let’s give our star a color. This is going to use a similar technique but is a little trickier. According to my super-scientific method of googling “what color are stars” it turns out that humans don’t see green or purple light emitted from stars, so we shouldn’t allow any of our stars to be green or purple!
We’ll need to adjust how we calculate our hue to omit green and purple hues. To do so, we’ll need to take a look at the how HSL hue values map to colors:
For our use case we’ll allow the following hue ranges:
With a bit of math, we can choose a random hue in one of those ranges:
``` function drawStar(size) { // Note upper range of red exceeds 360 const hueRange = randomItemInArray([ [330, 390], [40, 60], [190, 240], ]);
// Pass along chosen array as arguments let hue = randomInt(...hueRange);
// If red is greater than 360, use the remainder if (hue > 360) { hue = hue - 360; }
// We'll use higher saturation and lightness values for our
// star than our planets.
const saturation = randomInt(90, 100);
const lightness = randomInt(60, 80);
const color = hsl(${hue}, ${saturation}%, ${lightness}%);
return <circle
cx="${width / 2}"
cy="${height / 2}"
r="${size}"
fill="${color}"
/>;
}
```
Now our star should be red, yellow or blue:
See the Pen Generative SVG Solar Systems: Step 5 by Paul Hebert (@phebert) on CodePen.
Next Steps Awesome! We’ve built a program for procedurally generating solar system! But it’s still feeling a bit flat… It would be great if we could make this a little more lifelike with textures and realistic lighting. To do so, we’ll need to add a few more tools to our toolbox.
In part two of this series we’ll use SVG filters, gradients, and clipping paths to turn our flat solar system into something a little more lifelike:
See the Pen Generative SVG Solar Systems: Step 6 by Paul Hebert (@phebert) on CodePen.
Click “Refresh” to generate new solar systems Continue the journey with part two of this series: SVG Filters, Gradients, and Clip Paths
We recently enabled the Cloudinary WordPress plugin for a client site. It modifies the way WordPress handles media by automatically syncing your images and serving them from Cloudinary with optimizations. For images in blog posts, it works out of the box. However, our client’s site has a lot of custom templates, almost all of which use Timber filters to resize images and convert to JPG format, like so:
```
```
Unfortunately, arbitrary resizing of images like this results in the resized images being served from WordPress rather than Cloudinary. Timber has a helpful section in their docs explaining that this is a limitation of Timber when working with a CDN, because WordPress doesn’t know about the generated images.
As a result, we had a choice: Drop the filters and get the image from Cloudinary, but receive the full-sized asset, often over 3000 pixels wide. Or resize it with Timber, but have the image be served from WordPress without Cloudinary’s optimizations.
Thankfully, resizing a Cloudinary image is simple. It works by adding URL parameters to specify things like image dimensions and cropping method.
What we needed was a way to modify the URL of the image if it was served from Cloudinary, or pass it through Timber’s filters if it wasn’t. To do this, we added a custom Twig filter:
``` function optimize_image( $url, $width, $height, $format) { $parsed_url = parse_url($url); if ( $parsed_url['host'] == 'res.cloudinary.com' ) { $result = optimize_cloudinary_image($url, $width, $height); } else { $result = optimize_timber_image($url, $width, $height, $format); } return $result; }
```
This can be used the same way as the Timber filters:
```
```
Now we can send the image through a separate optimization function depending on whether the image is being served from Cloudinary or not.
You might think this step isn’t necessary — wouldn’t we know that all our images were served from Cloudinary? That’s not always true, however. For example, newly added images may take a bit of time to sync and be served from WordPress until they do. Not to mention that for local development and our staging environment, the Cloudinary plugin was disabled.
The Timber optimization function reproduces what was happening before with the resize and tojpg filters by calling the PHP helper methods directly:
``` function optimize_timber_image( $url, $width, $height, $format ) { $timber_image = TimberImageHelper::resize($url, $width, $height); if ($format === 'jpg') { $timber_image = TimberImageHelper::img_to_jpg($timber_image); } if ($format === 'webp') { $timber_image = TimberImageHelper::img_to_webp($timber_image); } return $timber_image; }
```
The result is a resized image that can optionally be forced to JPG or WebP format.
The Cloudinary optimization function simulates the effect of the Timber filters by injecting the appropriate Cloudinary URL parameters:
``` function optimize_cloudinary_image( $url, $width, $height ) { preg_match("/images\/(.*?\/)\/?v\d+\//", $url, $matches); $old_transforms = $matches[1]; $w = ',w_' . $width; $h = $height ? ',h_' . $height : ''; $new_transforms = 'c_fill' . $w . $h . '/f_auto,q_auto/'; if ($old_transforms) { $result = str_replace($old_transforms, $new_transforms, $url); } else { $result = str_replace('images/', 'images/' . $new_transforms, $url); } return $result; }
```
A few things to notice here:
$format passed in. That’s because we’re using Cloudinary’s f_auto to automatically serve the best file format the browser supports.preg_match to slice up the URL and find any existing transforms. We identify them as the bit of the URL between images/ and the Cloudinary ID.v followed by a string of digits. This is inferred in the Cloudinary docs but isn’t specified anywhere. The docs say “You cannot use ‘v’ followed by numeric characters as a folder name.” That matches our observations and seems to be a safe assumption.We construct our new transformations by adding the crop method, the specified width, the optional height, the automatic quality, and the automatic format parameters. Then we replace any existing transformations with our new transformations.
In a perfect world, we wouldn’t be doing this by manually hacking the Cloudinary URL. The Cloudinary WordPress plugin already has a lot of methods that do things like “given a WordPress attachment ID, construct a Cloudinary URL.” Unfortunately, they don’t expose any of these methods. If they did, this function would be a lot simpler.
And there you have it! We’ve added a new optimize_image filter that we can use in our custom templates in place of the Timber image filters. Our filter will inject the transformations we want into a Cloudinary image, or use the Timber methods for WordPress images.
Illustration by Arianna Chau Pleasantest is a library that integrates with Jest to help you write UI tests that interact with real browsers. It uses Puppeteer to launch and control browsers, Testing Library to find elements on the page, and jest-dom to make assertions against the DOM.
At Cloud Four, automated tests save us time by automatically checking for regressions in interactivity, accessibility, and appearance. We’ve used several different tools in the past, each with its own set of trade-offs, to help us ship quality interfaces. We created a new testing tool, Pleasantest, to make UI testing easier, more realistic, and more reliable.
Why a new testing tool? One tool we’ve used to test our UI components is jsdom, the DOM implementation that is included with Jest. This setup was great because it allowed us to use Testing Library, which helped us write tests that were resilient to changes and that tested the accessibility of our components. But while working with this setup, we ran into several issues related to the fact that jsdom doesn’t have a rendering engine so it is missing many browser features. When we write tests that use an emulated DOM without a rendering engine, the tests cannot give us the confidence that a real browser would. Also, polyfilling and stubbing out browser features missing from jsdom is time-consuming and tedious.
Another tool we’ve used is Cypress, which avoids many of jsdom’s problems. Cypress lets you write tests that run in real browsers, which helps improve confidence compared to tests that run in jsdom. Cypress is great at testing entire applications, where you point your Cypress tests to the URL of your app server. They recently added support for testing individual components. However, one of our main gripes with Cypress is that it is a separate test runner from what we use for our unit tests. Each time developers switch between writing a unit test for some logic and writing a UI test, they have to make the mental jump to remember how to use a separate test runner, different assertion syntax, and different conventions. Because of its design, Cypress implements its own functionality (command chains, aliases, custom commands) rather than supporting language features that developers are often familiar with (await, variables, functions). This leads to an increased barrier to entry for people who are already familiar with JavaScript.
The best of both worlds We began making Pleasantest as an experiment to see if we could create a testing tool that took what we liked from both kinds of tests. Pleasantest integrates with all of our favorite testing tools from the Testing Library ecosystem. It uses Puppeteer to avoid the problems associated with using an emulated DOM. You can render and test individual components, or point Pleasantest to a URL to load to test entire applications.
By writing tests using Pleasantest, we can maintain the quality of the work we ship, and we can ensure reliability and consistency in functionality and features as time goes on. We’ll walk through an example of how to test a component using Pleasantest.
Writing your first test with Pleasantest
Video demo of how to write a test using Pleasantest (this is the same as the below content in video form) For this example, we’ll write tests for a React component. We can start by installing Jest and Pleasantest, and the types for Jest for editor autocompletion:
``` npm i -D jest @types/jest pleasantest
```
The component we’ll test is an example modal from @reach/dialog. You can see the code for the demo on GitHub and you can preview it on Netlify.
We’ll start by creating a new test file, index.test.js, with an empty test:
``` test('Shows modal when button is pressed', async () => {
})
```
We can run the test by running npx jest --watch. It will rerun whenever we change the test file, or we can manually rerun it by pressing enter in the terminal.
To mark the test as a Pleasantest test, we’ll wrap the test function in withBrowser:
``` const { withBrowser } = require('pleasantest')
test( 'Shows modal when button is pressed', withBrowser(async () => {
}) )
```
In our example, there is an index.js file in the same folder as the test, which renders the button that opens the modal. We can tell Pleasantest to run that index.js file by using the utils.loadJS function. Since the index.js file renders the app into a <div> with an id of root, we’ll make sure that exists too:
``` const { withBrowser } = require('pleasantest')
test( 'Shows modal when button is pressed', withBrowser(async ({ utils }) => { await utils.injectHTML('
') await utils.loadJS('./index.js') }) )```
Next we’ll find the “Open Dialog” button using the getByRole query from Testing Library. In Pleasantest, all queries, matchers, and actions need to be awaited, because the communication with the browser is asynchronous. Note that the screen object needs to be added to the test function parameters. We’ll also use the queryByText query from Testing Library, and the expect(...).not.toBeInTheDocument() matcher from jest-dom to make sure that the modal contents are not present before the button is pressed.
``` test( 'Shows modal when button is pressed', withBrowser(async ({ utils, screen }) => { await utils.injectHTML('
') await utils.loadJS('./index.js')const button = await screen.getByRole('button', { name: /open dialog/i });
await expect(
await screen.queryByText(/I am a dialog/i),
).not.toBeInTheDocument();
}) );
```
Then we can click the button and make sure that the modal appears. Adding the user parameter to our test function gives us access to interaction methods like user.click():
``` test( 'Shows modal when button is pressed', withBrowser(async ({ utils, screen, user }) => { await utils.injectHTML('
') await utils.loadJS('./index.js')const button = await screen.getByRole('button', { name: /open dialog/i });
await expect(
await screen.queryByText(/I am a dialog/i)
).not.toBeInTheDocument();
await user.click(button);
await expect(await screen.queryByText(/I am a dialog/i)).toBeVisible();
}) );
```
This test covers the basic functionality of making sure the modal opens correctly. Next, we can add tests for the various ways to close the modal.
Testing closing the modal There are three ways to close the modal: The close button, clicking on the overlay outside the modal, and pressing the escape key. We’ll start with the escape key since it is the easiest.
Since the logic for rendering the component and opening the modal is the same for all the tests, we can create reusable render and openDialog functions (outside of the test call):
``` const render = async (utils) => { await utils.injectHTML('
'); await utils.loadJS('./index.js'); };const openDialog = async (screen, user) => { const button = await screen.getByRole('button', { name: /open dialog/i }); await user.click(button); };
```
Then we can create a new test and use the functions. We’ll use the page.keyboard.press method from Puppeteer to press the escape key.
``` test( 'Escape key closes modal', withBrowser(async ({ utils, screen, user, page }) => { await render(utils); await openDialog(screen, user);
await expect(await screen.queryByText(/I am a dialog/i)).toBeVisible();
await page.keyboard.press('Escape');
await expect(
await screen.queryByText(/I am a dialog/i)
).not.toBeInTheDocument();
}) );
```
One thing to keep in mind is that we ran the same queryByText query twice without assigning the result to a variable because we specifically want the test to re-query the DOM after the dialog is closed, rather than reusing the same result.
Testing to make sure the close button works correctly is nearly the same, but we’ll use the user.click method to click the button:
``` test( 'Close button closes modal', withBrowser(async ({ utils, screen, user }) => { await render(utils); await openDialog(screen, user);
await expect(await screen.queryByText(/I am a dialog/i)).toBeVisible();
const closeButton = await screen.getByRole('button', { name: /close/i });
await user.click(closeButton);
await expect(
await screen.queryByText(/I am a dialog/i)
).not.toBeInTheDocument();
}) );
```
Lastly, for testing clicking the overlay, we can use Puppeteer’s page.mouse.click to trigger a click at a specific x and y position:
``` test( 'Clicking outside modal closes modal', withBrowser(async ({ utils, screen, user, page }) => { await render(utils); await openDialog(screen, user);
await expect(await screen.queryByText(/I am a dialog/i)).toBeVisible();
// (10px, 10px) should be outside the modal
await page.mouse.click(10, 10);
await expect(
await screen.queryByText(/I am a dialog/i)
).not.toBeInTheDocument();
}) );
```
Screen reader and keyboard accessibility
Those tests cover the most obvious behaviors of the modal, but there is still more functionality to test. One important aspect of the implementation is the accessibility of the component. Pleasantest lets us use Testing Library queries and jest-dom matchers that help us make sure the elements have the right accessible roles and labels. We’ll use getByRole and expect(...).toHaveAccessibleName() to check the role and label of the modal:
``` test( 'Accessibility structure of modal', withBrowser(async ({ utils, screen, user }) => { await render(utils); await openDialog(screen, user); const modal = await screen.getByRole('dialog'); await expect(modal).toHaveAccessibleName('example dialog'); }) );
```
We can also test that the focus is trapped within the modal. This means that when you press tab and shift-tab to navigate through the focusable elements, it should only cycle through elements inside the modal, and skip anything behind the modal. Puppeteer’s page.keyboard.press method, and jest-dom’s expect(...).toHaveFocus() let us test cycling through the focusable button and links inside the dialog.
``` test( 'Focus is trapped in the modal when it opens', withBrowser(async ({ utils, screen, user, page }) => { await render(utils); await openDialog(screen, user);
// When the modal is opened, the close button should be focused automatically
const closeButton = await screen.getByRole('button', { name: /close/i });
await expect(closeButton).toHaveFocus();
await page.keyboard.press('Tab');
const firstLink = await screen.getByRole('link', { name: /here/i });
await expect(firstLink).toHaveFocus();
await page.keyboard.press('Tab');
const secondLink = await screen.getByRole('link', { name: /focusable/i });
await expect(secondLink).toHaveFocus();
// After pressing tab the third time it should cycle back to the close button,
// instead of focusing on content that is hidden behind the modal
await page.keyboard.press('Tab');
await expect(closeButton).toHaveFocus();
// Shift-tab should cycle back to the last focusable element within the modal
await page.keyboard.down('Shift');
await page.keyboard.press('Tab');
await page.keyboard.up('Shift');
await expect(secondLink).toHaveFocus();
}) );
```
This test suite now tests the user-facing functionality of the component and will catch regressions before users do. You can see the full code for this example and download Pleasantest on GitHub.
We’re excited to use Pleasantest on our projects. Going forward, we’ll continue to add features to Pleasantest that will make testing easier and more comprehensive. We encourage you to give it a try and let us know how it works!
Illustration by Aileen Jeffries
Some of the largest sporting goods e-commerce sites don’t provide an accessible experience for sale prices. A few small changes can significantly enhance the experience.
Imagine yourself excitedly walking into a physical store looking to buy a new shirt. After browsing for a couple of minutes, you find a shirt you are thrilled to purchase.
You look at the price tag, and with a confused expression, discover two prices with no visual difference: $25 $35. What is the price of the shirt?
You decide to find a store employee to ask them to clarify the price of the shirt.
“Hello! Can you help me? What is the price of this shirt?”
With a smile, the store employee responds, “$25. $35.”
Your confused expression intensifies. You ask again, “Sorry, perhaps I misunderstood. What is the price of this shirt?”
With an even larger smile, the store employee responds, “$25. $35.”
You look around, wondering if someone is trying to pull a prank on you. Determined, you ask one more time, “I don’t understand, you are giving me two prices. I’ll ask once more, what is the price of this shirt?”
With their smile persisting, the store employee once again responds, “$25. $35.”
You take a deep breath and politely say, “Thank you,” and decide today wasn’t the day to purchase that shirt, leaving it behind as you exit the store.
Prices, prices, prices You might think, “That was a frustrating experience.” I agree! You might then push back and say, “But that’s not a real experience for customers.” And to that, I respond, “You’d be surprised!”
I got curious the other day how some of the largest sporting goods e-commerce sites handle the sale price customer experience for their products. Visually, I didn’t see anything unexpected. A crossed-out price next to a non-crossed-out price with some visual styles provided enough information and context to understand why there were two prices: the original price and the sale price. You can imagine something like the following:
See the Pen
Sale price, original experience by Gerardo Rodriguez (@gerardorodriguez)
on CodePen.
Once I turned on my screen reader1, I discovered the auditory experience was as confusing and frustrating as the imaginary experience above. My screen reader read to me something similar to:
“The Perfect Shirt”
“5 colors available”
“$25. $35.”
In the following video, I captured the frustrating customer experience on both Nike and Adidas2:
A confusing and frustrating customer experience when encountering sale prices on the Nike and Adidas e-commerce websites I was read two prices, with no differentiation, no further information or context. Why two prices? Which one is the correct price?
This is not an inclusive experience.
Creating a more inclusive experience How might we make this a more inclusive experience? Let’s explore one possible solution together.
Below is the HTML that mimics what I found as the current experience:
```
The Perfect Shirt
5 colors available
```
First, let’s see if we can use more semantic markup:
```
```
<span> tag around the current price should suffice.<s> tag for the regular price and get the visual strikethrough treatment for free!We need to figure out how to provide some more audible context to help the customer understand why there are two prices. Ideally, my screen reader should say something like, “On sale for $25, regular price $35”.
We might be tempted to use an aria-label, but since the aria-label doesn’t translate, we look at a different solution.
Instead, let’s look at adding visually hidden text to provide extra context. This text will only be accessible to assistive technologies like a screen reader and won’t impact the visual design:
```
```
And the rules for the u-visually-hidden CSS class:
``` /* * Hide the content visually, still accessible to screen readers. / .u-visually-hidden:not(:focus):not(:active) { border: 0; clip: rect(0 0 0 0); clip-path: polygon(0px 0px, 0px 0px, 0px 0px); -webkit-clip-path: polygon(0px 0px, 0px 0px, 0px 0px); height: 1px; margin: -1px; overflow: hidden; padding: 0; position: absolute; width: 1px; white-space: nowrap; }
```
Giving it a quick test with my screen reader, I hear something like:
“The Perfect Shirt”
“5 colors available”
“On sale for $25”
“$25”
“Regular price $35”
“$35”
Progress! One last detail, we don’t want to hear the same price repeated multiple times. We can use aria-hidden to hide the visual price from the accessibility tree, which the screen reader reads from:
```
```
Now, if we listen to the experience we hear something like:
“The Perfect Shirt”
“5 colors available”
“On sale for $25”
“Regular price $35”
See the Pen
Sale price, more inclusive experience by Gerardo Rodriguez (@gerardorodriguez)
on CodePen.
So much better! Now we have a much more inclusive experience. Creating a more accessible experience did not impact the visual design while significantly enhancing the auditory experience. Win, win!
Slightly modified approach You may have noticed that the price repeats twice in the HTML markup. If this proves a challenge (technically or otherwise), we can look at a slightly different approach to avoid this:
```
```
The auditory experience will be slightly different because there will be additional pauses between the visually hidden text label and the price. It’ll be something like (with VoiceOver + Safari):
“The Perfect Shirt”
“5 colors available”
“On sale for:”
“$25”
“Regular price:”
“$35”
See the Pen
Sale price, a more inclusive experience by Gerardo Rodriguez (@gerardorodriguez)
on CodePen.
I prefer having the price read together with the visually hidden text, but that is a personal preference. This is still a much better experience than where it started.
Thoughtful, inclusive, accessible design Regardless of your role in contributing to the web, having a fundamental understanding and basic experience using assistive technology like a screen reader is very helpful. As with any creative craft, there is no one correct answer. The best solution will depend on multiple factors, including your audience and project goals. We all strive to create the best user experiences we can; there’s an opportunity to elevate the user experience design to be more accessible. Let’s ensure we consider all our customers when designing experiences.
Extra Resources * Deque University Screen Reader Keyboard Shortcuts and Gestures guides * See No Evil: Hidden Content and Accessibility by Paul Hebert * The A11Y Project – Hide content