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.

Collection cover image

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.

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;
}

This carousel uses CSS scroll snap to create smooth, performant slide transitions that do not cause layout shifts.

Learn more

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.

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>

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.

Learn more

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>

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.

Learn more

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.

<img> tag

<img width="267" height="400" src="dog.jpg">

This image loads without causing layout shifts.

Learn more

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>

These responsive images load without causing layout shifts.

Learn more

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.

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);
    }
}

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.

Learn more

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.

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;
}

This banner slides-in from the bottom of the page without causing layout shifts.

Learn more

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;
}

Modals can be used as an alternative to banner notices.

Learn more

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;
}

This sticky footer banner does not cause layout shifts when it is inserted into the page.

Learn more

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.

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;
}

These placeholders provide users with a visual indication that new content is loading; they also help prevent layout shifts.

Learn more

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.

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;
}

This video loads without causing layout shifts and displays a poster image.

Learn more

GIF-style video

<video autoplay loop muted playsinline width="320" height="240">
    <source src="dog.mp4" type="video/mp4">
</video>

This <video> tag looks and feels like a GIF but is far more performant.

Learn more