Compare & CompareCaption

The lang attribute can only have one language associated with it. This means the <html> attribute can only have one language, even if there are multiple languages on the page. Set lang to the primary language of the page.

Don't
<html lang="ar,en,fr,pt">...</html>
Multiple languages are not supported.
Do
<html lang="ar">...</html>
Set only the page's primary language. In this case, the language is Arabic.

Similar to buttons, links primarily get their accessible name from their text content. A nice trick when creating a link is to put the most meaningful piece of text into the link itself, rather than filler words like "Here" or "Read More."

Not descriptive enough
Check out our guide to web performance <a href="/guide">here</a>.
Useful content!
Check out <a href="/guide">our guide to web performance</a>.

Check if an animation triggers layout

An animation that moves an element using something other than transform, is likely to be slow. In the following example, I have achieved the same visual result animating top and left, and using transform.

Don't
.box {
  position: absolute;
  top: 10px;
  left: 10px;
  animation: move 3s ease infinite;
}

@keyframes move {
  50% {
     top: calc(90vh - 160px);
     left: calc(90vw - 200px);
  }
}
Do
.box {
  position: absolute;
  top: 10px;
  left: 10px;
  animation: move 3s ease infinite;
}

@keyframes move {
  50% {
     transform: translate(calc(90vw - 200px), calc(90vh - 160px));
  }
}

You can test this in the following two Glitch examples, and explore performance using DevTools.

With the same markup, we can replace: padding-top: 56.25% with aspect-ratio: 16 / 9, setting aspect-ratio to a specified ratio of width / height.

Using padding-top
.container {
  width: 100%;
  padding-top: 56.25%;
}
Using aspect-ratio
.container {
  width: 100%;
  aspect-ratio: 16 / 9;
}

Using aspect-ratio instead of padding-top is much more clear, and does not overhaul the padding property to do something outside of its usual scope.

Yeah, that's right, I'm using reduce to chain a sequence of promises. I'm so smart. But this is a bit of so smart coding you're better off without.

However, when converting the above to an async function, it's tempting to go too sequential:

Not recommended - too sequential
async function logInOrder(urls) {
  for (const url of urls) {
    const response = await fetch(url);
    console.log(await response.text());
  }
}
Looks much neater, but my second fetch doesn't begin until my first fetch has been fully read, and so on. This is much slower than the promises example that performs the fetches in parallel. Thankfully there's an ideal middle-ground.
Recommended - nice and parallel
function markHandled(...promises) {
  Promise.allSettled(promises);
}

async function logInOrder(urls) {
  // fetch all the URLs in parallel
  const textPromises = urls.map(async (url) => {
    const response = await fetch(url);
    return response.text();
  });

  markHandled(...textPromises);

  // log them in sequence
  for (const textPromise of textPromises) {
    console.log(await textPromise);
  }
}
In this example, the URLs are fetched and read in parallel, but the "smart" reduce bit is replaced with a standard, boring, readable for-loop.

Writing Houdini custom properties

Here's an example of setting a custom property (think: CSS variable), but now with a syntax (type), initial value (fallback), and inheritance boolean (does it inherit the value from its parent or not?). The current way to do this is through CSS.registerProperty() in JavaScript, but in Chromium 85 and later, the @property syntax will be supported in your CSS files:

Separate JavaScript file (Chromium 78)
CSS.registerProperty({
  name: '--colorPrimary',
  syntax: '',
  initialValue: 'magenta',
  inherits: false
});
Included in CSS file (Chromium 85)
@property --colorPrimary {
  syntax: '';
  initial-value: magenta;
  inherits: false;
}

Now you can access --colorPrimary like any other CSS custom property, via var(--colorPrimary). However, the difference here is that --colorPrimary isn't just read as a string. It has data!

CSS backdrop-filter applies one or more effects to an element that is translucent or transparent. To understand that, consider the images below.

No foreground transparency
A triangle superimposed on a circle. The circle can't be seen through the triangle.
.frosty-glass-pane {
  backdrop-filter: blur(2px);
}
Foreground transparency
A triangle superimposed on a circle. The triangle is translucent, allowing the circle to be seen through it.
.frosty-glass-pane {
  opacity: .9;
  backdrop-filter: blur(2px);
}

The image on the left shows how overlapping elements would be rendered if backdrop-filter were not used or supported. The image on the right applies a blurring effect using backdrop-filter. Notice that it uses opacity in addition to backdrop-filter. Without opacity, there would be nothing to apply blurring to. It almost goes without saying that if opacity is set to 1 (fully opaque) there will be no effect on the background.

Unlike the unload event, however, there are legitimate uses for beforeunload. For example, when you want to warn the user that they have unsaved changes they'll lose if they leave the page. In this case, it's recommended that you only add beforeunload listeners when a user has unsaved changes and then remove them immediately after the unsaved changes are saved.

Don't
window.addEventListener('beforeunload', (event) => {
  if (pageHasUnsavedChanges()) {
    event.preventDefault();
    return event.returnValue = 'Are you sure you want to exit?';
  }
});
The code above adds a beforeunload listener unconditionally.
Do
function beforeUnloadListener(event) {
  event.preventDefault();
  return event.returnValue = 'Are you sure you want to exit?';
};

// A function that invokes a callback when the page has unsaved changes.
onPageHasUnsavedChanges(() => {
  window.addEventListener('beforeunload', beforeUnloadListener);
});

// A function that invokes a callback when the page's unsaved changes are resolved.
onAllChangesSaved(() => {
  window.removeEventListener('beforeunload', beforeUnloadListener);
});
The code above only adds the beforeunload listener when it's needed (and removes it when it's not).

Minimize use of Cache-Control: no-store

Cache-Control: no-store is an HTTP header web servers can set on responses that instructs the browser not to store the response in any HTTP cache. This should be used for resources containing sensitive user information, for example pages behind a login.

The fieldset element, which contains each input group (.fieldset-item), is using gap: 1px to create the hairline borders between elements. No tricky border solution!

Filled gap
.grid {
  display: grid;
  gap: 1px;
  background: var(--bg-surface-1);

  & > .fieldset-item {
    background: var(--bg-surface-2);
  }
}
Border trick
.grid {
  display: grid;

  & > .fieldset-item {
    background: var(--bg-surface-2);

    &:not(:last-child) {
      border-bottom: 1px solid var(--bg-surface-1);
    }
  }
}

Natural grid wrapping

The most complex layout ended up being the macro layout, the logical layout system between <main> and <form>.

input
<input
  type="checkbox"
  id="text-notifications"
  name="text-notifications"
>
label
<label for="text-notifications">
  <h3>Text Messages</h3>
  <small>Get notified about all text messages sent to your device</small>
</label>

The fieldset element, which contains each input group (.fieldset-item), is using gap: 1px to create the hairline borders between elements. No tricky border solution!

Filled gap
.grid {
  display: grid;
  gap: 1px;
  background: var(--bg-surface-1);

  & > .fieldset-item {
    background: var(--bg-surface-2);
  }
}
Border trick
.grid {
  display: grid;

  & > .fieldset-item {
    background: var(--bg-surface-2);

    &:not(:last-child) {
      border-bottom: 1px solid var(--bg-surface-1);
    }
  }
}

Tabs <header> layout

The next layout is nearly the same: I use flex to create vertical ordering.

HTML
<snap-tabs>
  <header>
    <nav></nav>
    <span class="snap-indicator"></span>
  </header>
  <section></section>
</snap-tabs>
CSS
header {
  display: flex;
  flex-direction: column;
}

The .snap-indicator should travel horizontally with the group of links, and this header layout helps set that stage. No absolute positioned elements here!

Gentle Flex is a truer centering-only strategy. It's soft and gentle, because unlike place-content: center, no children's box sizes are changed during the centering. As gently as possible, all items are stacked, centered, and spaced.

.gentle-flex {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1ch;
}
Pros
  • Only handles alignment, direction, and distribution
  • Edits and maintenance are all in one spot
  • Gap guarantees equal spacing amongst n children
Cons
  • Most lines of code

Great for both macro and micro layouts.

Usage

gap accepts any CSS length or percentage as a value.

.gap-example {
  display: grid;
  gap: 10px;
  gap: 2ch;
  gap: 5%;
  gap: 1em;
  gap: 3vmax;
}


Gap can be passed 1 length, which will be used for both row and column.

Shorthand
.grid {
  display: grid;
  gap: 10px;
}
Set both rows and columns together at once
Expanded
.grid {
  display: grid;
  row-gap: 10px;
  column-gap: 10px;
}


Gap can be passed 2 lengths, which will be used for row and column.

Shorthand
.grid {
  display: grid;
  gap: 10px 5%;
}
Set both rows and columns separately at once
Expanded
.grid {
  display: grid;
  row-gap: 10px;
  column-gap: 5%;
}