Optimizing CLS Score in NextJS
Introduction
In previous articles, I provided guidance on Core Web Vitals and how to optimize metrics such as LCP and INP. Now, we will continue to explore the final metric, which is CLS (Cumulative Layout Shift). Receiving data from multiple API requests at different times can easily cause sudden layout changes. Here are the solutions to address this:
- Explicit Dimensions: This involves declaring exact width and height for elements (especially images, videos and iframes) in HTML or CSS before they are downloaded.
- This helps the browser know the element size to build the layout beforehand. Without this, the browser will default to treating the element as 0px X 0px before assets are loaded.
- Once assets finish loading, they will suddenly expand in size, pushing surrounding content to different positions and creating Layout Shift errors.
- Aspect Ratio: Instead of setting hard pixels, using Aspect Ratio is a modern solution to solve Explicit Dimensions by defining the ratio between the width and height of an element.
- When used, you only need to provide one size dimension like width or height and the browser can calculate the remaining value based on that ratio.
- The browser will use this value to reserve space. Once assets are fully loaded, they are simply placed here, ensuring the layout does not change.
Skeleton Screens: Instead of displaying a single loading spinner, use Skeletons with dimensions that exactly match the content after loading. This reserves space for the content and eliminates CLS.
Default State: Define the initial state for data variables so that the layout structure is formed from the beginning.
Detail
Create file app/cls/types.ts
export interface BannerData {
id: string
imageUrl: string
title: string
}
export interface ProductData {
id: number
title: string
price: number
thumbnail: string
}
export interface DashboardData {
banner: BannerData | null
products: ProductData[]
}
Create file app/cls/loading.tsx
'use client'
import {Skeleton} from 'antd'
export default function DashboardLoading() {
return (
<div className="p-8 max-w-6xl mx-auto space-y-10 bg-gray-50 min-h-screen">
<div className="space-y-4">
<Skeleton.Input active size="large" style={{width: 300}} />
<Skeleton.Input active size="small" style={{width: 500}} />
</div>
<div className="relative w-full aspect-[21/9] md:aspect-[21/7] rounded-3xl overflow-hidden shadow-sm bg-white">
<Skeleton.Button active block className="!h-full" />
</div>
<div className="space-y-6">
<Skeleton.Input active size="large" style={{width: 200}} />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{Array.from({length: 4}).map((_, i) => (
<div
key={i}
className="flex flex-col space-y-4 p-4 bg-white rounded-2xl shadow-sm"
>
<div className="relative w-full aspect-square rounded-xl overflow-hidden">
<Skeleton.Button active block className="!h-full" />
</div>
<div className="space-y-3">
<Skeleton.Input
active
block
style={{width: '90%'}}
size="small"
/>
<Skeleton.Input
active
block
style={{width: '50%'}}
size="small"
/>
</div>
</div>
))}
</div>
</div>
</div>
)
}
- Unlike other metrics, the content in this loading file is the main factor that helps avoid frequent layout changes.
- You can see that I defined Skeletons for components that will show on the UI such as title, banner, section text and product list. This will be the display part before actual content is returned.
- Explicit Dimensions & Aspect Ratio:
aspect-square,aspect-[21/9]andmd:aspect-[21/7]are classes to determine the ratio of internal content before assets finish loading for display.
Create file app/cls/Dashboard.tsx
'use client'
import {Badge, Typography} from 'antd'
import Image from 'next/image'
import type {DashboardData} from './types'
const {Title, Text} = Typography
interface Props {
initialData: DashboardData
}
export default function Dashboard({initialData}: Props) {
const {banner, products} = initialData
return (
<div className="space-y-12">
{banner && (
<section className="relative w-full aspect-[21/9] md:aspect-[21/7] overflow-hidden rounded-3xl shadow-xl border border-white">
<Image
src={banner.imageUrl}
alt={banner.title}
fill
priority
sizes="(max-width: 1280px) 100vw, 1280px"
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-r from-black/60 to-transparent flex items-center p-12">
<Title className="!text-white !m-0 !text-5xl font-black max-w-2xl drop-shadow-md">
{banner.title}
</Title>
</div>
</section>
)}
<section className="space-y-6">
<Title level={3}>Featured Products</Title>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{products.map(product => (
<div key={product.id} className="group flex flex-col space-y-4">
<div className="relative w-full aspect-square bg-white rounded-2xl overflow-hidden shadow-sm border border-gray-100">
<Image
src={product.thumbnail}
alt={product.title}
fill
sizes="(max-width: 768px) 100vw, 25vw"
className="object-contain p-4 group-hover:scale-105 transition-transform duration-300"
/>
</div>
<div className="px-1">
<Text className="font-bold text-lg block truncate text-gray-800">
{product.title}
</Text>
<Badge
status="success"
text={
<span className="font-extrabold text-green-600 text-base">
${product.price}
</span>
}
/>
</div>
</div>
))}
</div>
</section>
</div>
)
}
In here, you only need to reuse the classes as defined in the loading.tsx file like aspect-square, aspect-[21/9] and md:aspect-[21/7] to ensure the banner and product images will show in the correct ratio after loading.
Create file app/cls/page.tsx to simulate returned data for the Dashboard component, so I will not explain much.
import Dashboard from './Dashboard'
import type {DashboardData} from './types'
async function getDashboardData(): Promise<DashboardData> {
await new Promise(resolve => setTimeout(resolve, 2500))
const res = await fetch('https://dummyjson.com/products?limit=8', {
next: {revalidate: 60},
})
if (res.ok) {
const data = await res.json()
return {
banner: {
id: 'b1',
imageUrl:
'https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe',
title: 'Banner Image',
},
products: data.products,
}
}
return {banner: null, products: []}
}
export default async function OptimizedCLSPage() {
const data = await getDashboardData()
return (
<main className="p-8 max-w-6xl mx-auto space-y-10 bg-gray-50 min-h-screen">
<div className="space-y-2">
<h1 className="text-3xl font-black text-gray-900">CLS Dashboard</h1>
<p className="text-gray-500">Description</p>
</div>
<Dashboard initialData={data} />
</main>
)
}
The result will be as follows.
Happy coding!
Comments
Post a Comment