Web Vitals patterns
A collection of common UX patterns optimized for Core Web Vitals. This collection includes patterns that are often tricky to implement without hurting your Core Web Vitals scores. You can use the code in these examples to help ensure your projects stay on the right track.
Carousels
A carousel is a UX component that displays content in a slideshow-like manner. Large, above-the-fold carousels often contain a page's Largest Contentful Paint (LCP) element, and therefore can have a significant impact on LCP. In addition, a surprising number of carousels use non-composited animations that can contribute to Cumulative Layout Shift (CLS). On pages with autoplaying carousels, this has the potential to cause infinite layout shifts.
To learn about performance and UX best practices for carousels, see Carousel Best Practices. This carousel uses CSS scroll snap to create smooth, performant slide transitions that do not cause layout shifts.Autoplay carousel
<div id="carousel">
<div id="slide-container">
<div class="slide" data-slideIndex="0">
<div class="slide-banner">Tour the Empire State Building! <a href="">Buy tickets now.</a></div>
<img width="1200" height="600" src="./newyork.jpg">
</div>
<div class="slide" data-slideIndex="1">
<div class="slide-banner">Ride the Shinkansen! <a href="">Buy tickets now.</a></div>
<img width="1200" height="600" src="./tokyo.jpg">
</div>
<div class="slide" data-slideIndex="2">
<div class="slide-banner">Discover relaxation! <a href="">Buy tickets now.</a></div>
<img width="1200" height="600" src="./beach.jpg">
</div>
<div class="slide" data-slideIndex="3">
<div class="slide-banner">See penguins! <a href="">Buy tickets now.</a></div>
<img width="1200" height="600" src="./penguins.jpg">
</div>
<div class="slide" data-slideIndex="4">
<div class="slide-banner">Take a ride on the wheel! <a href="">Buy tickets now.</a></div>
<img width="1200" height="600" src="./wheel.jpg">
</div>
</div>
<div id="back-button" class="arrow back">←</div>
<div id="forward-button" class="arrow forward">→</div>
<div class="slide-indicators">
<div class="slide-indicator active"></div>
<div class="slide-indicator"></div>
<div class="slide-indicator"></div>
<div class="slide-indicator"></div>
<div class="slide-indicator"></div>
</div>
</div>
function autoplayCarousel() {
const carouselEl = document.getElementById("carousel");
const slideContainerEl = carouselEl.querySelector("#slide-container");
const slideEl = carouselEl.querySelector(".slide");
let slideWidth = slideEl.offsetWidth;
// Add click handlers
document.querySelector("#back-button")
.addEventListener("click", () => navigate("backward"));
document.querySelector("#forward-button")
.addEventListener("click", () => navigate("forward"));
document.querySelectorAll(".slide-indicator")
.forEach((dot, index) => {
dot.addEventListener("click", () => navigate(index));
dot.addEventListener("mouseenter", () => clearInterval(autoplay));
});
// Add keyboard handlers
document.addEventListener('keydown', (e) => {
if (e.code === 'ArrowLeft') {
clearInterval(autoplay);
navigate("backward");
} else if (e.code === 'ArrowRight') {
clearInterval(autoplay);
navigate("forward");
}
});
// Add resize handler
window.addEventListener('resize', () => {
slideWidth = slideEl.offsetWidth;
});
// Autoplay
const autoplay = setInterval(() => navigate("forward"), 3000);
slideContainerEl.addEventListener("mouseenter", () => clearInterval(autoplay));
// Slide transition
const getNewScrollPosition = (arg) => {
const gap = 10;
const maxScrollLeft = slideContainerEl.scrollWidth - slideWidth;
if (arg === "forward") {
const x = slideContainerEl.scrollLeft + slideWidth + gap;
return x <= maxScrollLeft ? x : 0;
} else if (arg === "backward") {
const x = slideContainerEl.scrollLeft - slideWidth - gap;
return x >= 0 ? x : maxScrollLeft;
} else if (typeof arg === "number") {
const x = arg * (slideWidth + gap);
return x;
}
}
const navigate = (arg) => {
slideContainerEl.scrollLeft = getNewScrollPosition(arg);
}
// Slide indicators
const slideObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const slideIndex = entry.target.dataset.slideindex;
carouselEl.querySelector('.slide-indicator.active').classList.remove('active');
carouselEl.querySelectorAll('.slide-indicator')[slideIndex].classList.add('active');
}
});
}, { root: slideContainerEl, threshold: .1 });
document.querySelectorAll('.slide').forEach((slide) => {
slideObserver.observe(slide);
});
}
autoplayCarousel();
#carousel {
max-width: 1200px;
display: flex;
flex-direction: column;
margin: 0 auto;
position: relative;
}
.slide-indicators {
display: flex;
justify-content: center;
}
.slide-indicator {
height: 44px;
width: 50px;
display: flex;
justify-items: center;
cursor: pointer;
}
.slide-indicator:after {
content: "";
background-color: #878787;
height: 10px;
margin-top: 10px;
width: 40px;
}
.slide-indicator.active:after,
.slide-indicator:hover:after {
background-color: #000000;
}
.slide-banner {
background-color: #000000;
color: #ffffff;
position: absolute;
left: 0;
bottom: 20px;
padding: 15px;
font-size: 2.5vw;
}
.slide-banner a {
color: #ffffff;
}
#slide-container {
scroll-snap-type: x mandatory;
overflow-x: scroll;
overflow-y: hidden;
display: flex;
align-items: center;
height: 100%;
gap: 10px;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
}
.slide {
scroll-snap-align: center;
position: relative;
min-width: 100%;
padding-top: 50%;
}
.slide img {
height: 100%;
width: auto;
position: absolute;
top: 0;
left: 0;
}
.arrow {
color: #ffffff;
height: 20px;
width: 20px;
background-color: #000000;
position: absolute;
padding: 10px;
opacity: .3;
cursor: pointer;
}
.arrow.back {
left: 10px;
top: 10px;
}
.arrow.forward {
right: 10px;
top: 10px;
}
.arrow:hover {
opacity: 1;
}
Fonts
If a web font has not been loaded, browsers typically delay rendering any text that uses the web font. In many situations, this delays First Contentful Paint (FCP). In some situations, this delays Largest Contentful Paint (LCP).
In addition, fonts can cause layout shifts. These layout shifts occur when a web font and its fallback font take up different amounts of space on the page.
For more information, see Best practices for fonts. This demo combines two performance techniques to deliver a self-hosted font as quickly as possible: use of inline font declarations and use of the WOFF2 font format. The demo combines two performance techniques to load a third-party font as quickly as possible: use of inline font declarations and use of preconnect resource hints.Self-hosted fonts
<head>
<style>
@font-face {
font-family: 'Google Sans';
src: url("GoogleSans-Regular.woff2") format('woff2');
font-display: swap;
}
body {
font-family: system-ui;
font-size: 1em;
}
h1 {
font-family: 'Google Sans', sans-serif;
font-size: 3em;
}
</style>
</head>
Third-party fonts
<head>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Zen+Tokyo+Zoo&display=swap" rel="stylesheet">
<style>
body {
font-family: system-ui;
font-size: 1em;
}
h1 {
font-family: 'Zen Tokyo Zoo', sans-serif;
font-size: 3em;
}
</style>
</head>
Images
Images can cause layout shifts if they load after the surrounding page has already been rendered. This issue is more prominent in situations where images are slow to load - for example, on a slow connection or when loading an image with a particularly large file size. This image loads without causing layout shifts. These responsive images load without causing layout shifts.<img> tag
<img width="267" height="400" src="dog.jpg">
Responsive images and art direction
<!-- Using density descriptors -->
<img width="480" height="330"
srcset="cat-1x.jpg, cat-2x.jpg 2x, cat-3x.jpg 3x"
src="cat-1x.jpg"
alt="Photo of a cat on a green background">
<!-- Using width descriptors -->
<img width="256" height="128"
srcset="dog-256w.jpg 256w, dog-512w.jpg 512w, dog-1028w.jpg 1028w"
src="dog-256w.jpg"
alt="Photo of a dog on a orange background">
<!-- Picture tag -->
<picture>
<source media="(max-width: 720px)" width="600" height="300" srcset="newyork-rectangle.jpg" />
<source media="(min-width: 721px)" width="600" height="600" srcset="newyork-square-1x.jpg, newyork-square-2x.jpg 2x, newyork-square-3x.jpg 3x" />
<img src="newyork-rectangle.jpg" width="600" height="300" alt="Photo of the Empire State Building">
</picture>
Infinite scroll
Infinite scroll is a UX pattern where content is continuously added to the page as a user scrolls. Some implementations of infinite scroll can be a source of layout shifts. In this implementation of infinite scroll there are never any layout shifts - regardless of how long it takes the server to respond with new content.Infinite scroll
<div id="infinite-scroll-container">
<div id="sentinel"></div>
<div class="item">A</div>
<div class="item">B</div>
<div class="item">C</div>
<div class="item">D</div>
<div class="item">E</div>
<button id="infinite-scroll-button" disabled>
<span class="disabled-text">Loading more items...</span>
<span class="active-text">Show more</span>
</button>
</div>
function infiniteScroll() {
let responseBuffer = [];
let hasMore;
let requestPending = false;
const loadingButtonEl = document.querySelector('#infinite-scroll-button');
const containerEl = document.querySelector('#infinite-scroll-container');
const sentinelEl = document.querySelector("#sentinel");
const insertNewItems = () => {
while (responseBuffer.length > 0) {
const data = responseBuffer.shift();
const el = document.createElement("div");
el.textContent = data;
el.classList.add("item");
el.classList.add("new");
containerEl.insertBefore(el, loadingButtonEl);
console.log(`inserted: ${data}`);
}
sentinelObserver.observe(sentinelEl);
if (hasMore === false) {
loadingButtonEl.style = "display: none";
sentinelObserver.unobserve(sentinelEl);
listObserver.unobserve(loadingButtonEl);
}
loadingButtonEl.disabled = true
}
loadingButtonEl.addEventListener("click", insertNewItems);
const requestHandler = () => {
if (requestPending) return;
console.log("making request");
requestPending = true;
fakeServer.fakeRequest().then((response) => {
console.log("server response", response);
requestPending = false;
responseBuffer = responseBuffer.concat(response.items);
hasMore = response.hasMore;
loadingButtonEl.disabled = false;;
});
}
const sentinelObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.intersectionRatio > 0) {
observer.unobserve(sentinelEl);
requestHandler();
}
});
});
const listObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.intersectionRatio > 0 && entry.intersectionRatio < 1) {
insertNewItems();
}
});
}, {
rootMargin: "0px 0px 200px 0px"
});
sentinelObserver.observe(sentinelEl);
listObserver.observe(loadingButtonEl);
}
infiniteScroll();
:root {
--active-button-primary: #0080ff;
--active-button-font:#ffffff;
--disabled-button-primary: #f5f5f5;
--disabled-button-secondary: #c4c4c4;
--disabled-button-font: #000000;
}
#infinite-scroll-container {
position: relative;
}
#sentinel {
position: absolute;
bottom: 150vh;
}
#infinite-scroll-button {
cursor: pointer;
border: none;
padding: 1em;
width: 100%;
font-size: 1em;
}
#infinite-scroll-button:enabled {
color: var(--active-button-font);
background-color: var(--active-button-primary)
}
#infinite-scroll-button:disabled {
color: var(--disabled-button-font);
background-color: var(--disabled-button-primary);
cursor: not-allowed;
animation: 3s ease-in-out infinite loadingAnimation;
}
#infinite-scroll-button:enabled .disabled-text {
display: none;
}
#infinite-scroll-button:disabled .active-text {
display: none;
}
@keyframes loadingAnimation {
0% {
background-color: var(--disabled-button-primary);
}
50% {
background-color: var(--disabled-button-secondary);
}
100% {
background-color: var(--disabled-button-primary);
}
}
Banners and notices
Banners and notices are a common source of layout shifts. Inserting a banner into the DOM after the surrounding page has already been rendered pushes the page elements below it further down the page.
For more information, see Best practices for cookie notices. This banner slides-in from the bottom of the page without causing layout shifts. Modals can be used as an alternative to banner notices. This sticky footer banner does not cause layout shifts when it is inserted into the page.Animated footer
<div id="banner" class="banner">
<button id="close-button" class="close-button" aria-label="close" tabindex="0">✕</button>
<div>
<h1>Notice</h1>
Lorem ipsum dolor sit amet.
</div>
</div>
document.getElementById("close-button").onclick = () => {
document.getElementById("banner").style.display = "none";
}
body {
overscroll-behavior-y: none;
font-family: system-ui;
padding: 2em;
background-color: #f4f4f4;
}
.banner {
animation: slideIn 1000ms ease-in-out;
position: fixed;
left: 0;
bottom: 0;
right: 0;
padding: 1rem;
background-color: white;
box-shadow: 0 0 10px rgba(0,0,0,0.2);
}
@keyframes slideIn {
from {
transform: translateY(100vh);
}
to {
transform: translateY(0vh);
}
}
.close-button {
background: transparent;
border: none;
padding: 1em;
font-size: 1em;
position: absolute;
right: 0;
top: 0;
cursor: pointer;
}
Modal
<div id="modal" class="modal">
<button id="close-button" class="close-button" aria-label="close" tabindex="0">✕</button>
<div>
<h1>Notice</h1>
Lorem ipsum dolor sit amet.
</div>
</div>
document.getElementById("close-button").onclick = () => {
document.getElementById("modal").style.display = "none";
}
body {
overscroll-behavior-y: none;
font-family: system-ui;
padding: 2em;
background-color: #f4f4f4;
}
.modal {
position: fixed;
top: 50%;
left: 50%;
min-width: 66%;
transform: translate(-50%, -50%);
padding: 1em 2em 2em 2em;
background-color: white;
box-shadow: 0 0 15px rgba(0,0,0,0.2);
}
.close-button {
background: transparent;
border: none;
padding: 1em;
font-size: 1em;
position: absolute;
right: 0;
top: 0;
cursor: pointer;
}
Sticky footer
<div id="banner" class="banner">
<button id="close-button" class="close-button" aria-label="close" tabindex="0">✕</button>
<div>
<h1>Notice</h1>
Lorem ipsum dolor sit amet.
</div>
</div>
document.getElementById("close-button").onclick = () => {
document.getElementById("banner").style.display = "none";
}
body {
font-family: system-ui;
padding: 2em;
overscroll-behavior-y: none;
background-color: #f4f4f4;
}
.banner {
position: fixed;
left: 0;
bottom: 0;
right: 0;
padding: 1rem;
background-color: white;
box-shadow: 0 0 10px rgba(0,0,0,0.2);
}
.close-button {
background: transparent;
border: none;
padding: 1em;
font-size: 1em;
position: absolute;
right: 0;
top: 0;
cursor: pointer;
}
Placeholders
Placeholders reserve space for future page content. Placeholders can be used as a solution to layout shifts caused by injecting page content; they can also be used in conjunction with lazy loading. These placeholders provide users with a visual indication that new content is loading; they also help prevent layout shifts.Placeholders
<div class="grid">
<div class="item">
<div class="image-container">
<img src="hats.jpg">
</div>
<div class="text-container">Hats</div>
</div>
<div class="item empty">
<div class="image-container">
<img src="">
</div>
<div class="text-container">Watches</div>
</div>
<div class="item empty">
<div class="image-container">
<img src="">
</div>
<div class="text-container"></div>
</div>
<div class="item empty">
<div class="image-container">
<img src="">
</div>
<div class="text-container"></div>
</div>
<div class="item empty">
<div class="image-container">
<img src="">
</div>
<div class="text-container"></div>
</div>
<div class="item empty">
<div class="image-container">
<img src="">
</div>
<div class="text-container"></div>
</div>
</div>
const data = [
{
description: "Watches",
src: "https://web-dev.imgix.net/image/j2RDdG43oidUy6AL6LovThjeX9c2/GMPpoERpp9aM5Rihk5F2.jpg"
},
{
description: "Shirt",
src: "shirt.jpg"
},
{
description: "Shorts",
src: "shorts.jpg"
},
{
description: "Sunglasses",
src: "sunglasses.jpg"
},
{
description: "Shoes",
src: "shoes.jpg"
}
];
document.querySelectorAll(".item.empty").forEach((el, index) => {
if (data[index]) {
el.classList = "item loaded";
el.querySelector("img").src = data[index].src;
el.querySelector(".text-container").innerHTML = data[index].description;
}
});
:root {
--placeholder-primary: #eeeeee;
--placeholder-secondary: #cccccc;
}
.grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 1em;
width: 100%;
max-width: 650px;
margin: 1em 0em;
}
.item {
display: grid;
gap: .5em;
width: 200px;
}
.text-container {
font-size: 1em;
height: 1.5em;
text-align: center;
font-weight: bold;
}
.image-container {
width: 200px;
height: 200px;;
animation: placeholder ease-in-out 2s infinite;
}
.image-container img {
width: 100%;
}
@keyframes placeholder {
0% {
background-color: var(--placeholder-primary);
}
50% {
background-color: var(--placeholder-secondary);
}
100% {
background-color: var(--placeholder-primary);
}
}
@keyframes fadeIn {
0% {
opacity: 0%;
}
100% {
opacity: 100%;
}
}
.item.loaded .image-container {
animation: none;
}
.item.loaded .image-container img{
animation: fadeIn linear .5s;
}
Video
Video can impact Web Vitals by being a source of layout shifts. In addition, in some scenarios large video files can delay LCP by monopolizing network resources and delaying the loading of other page resources. This video loads without causing layout shifts and displays a poster image. This <video> tag looks and feels like a GIF but is far more performant.Video
<video controls width="960" height="540" poster="flower-960-poster.png">
<source src="flower-960.mp4" type="video/mp4">
</video>
video {
max-width: 100%;
height: auto;
}
GIF-style video
<video autoplay loop muted playsinline width="320" height="240">
<source src="dog.mp4" type="video/mp4">
</video>