AspectRatio
A container component that enforces a fixed width-to-height ratio for its children, ensuring images, videos, and other media maintain consistent proportions regardless of container width.
Use Cases
- Display responsive images that maintain their aspect ratio across screen sizes
- Embed videos (YouTube, Vimeo) at standard 16:9 without layout shift
- Create uniform thumbnail grids where all items share the same shape
- Build square avatar or profile image containers
- Maintain consistent card image areas in a responsive grid
- Prevent cumulative layout shift (CLS) by reserving the correct vertical space before media loads
- Frame maps, charts, or canvases that require a fixed proportion
Simplest Implementation
interface AspectRatioProps {
ratio?: number;
children: React.ReactNode;
}
export function AspectRatio({ ratio = 16 / 9, children }: AspectRatioProps) {
return (
<div className="relative w-full" style={{ paddingBottom: `${(1 / ratio) * 100}%` }}>
<div className="absolute inset-0">{children}</div>
</div>
);
}Uses the classic padding-bottom trick to establish the height from the width. The inner absolute inset-0 container fills the reserved space and holds the child content. No "use client" is needed since there is no interactivity.
Variations
16:9 Ratio
interface AspectRatioProps {
children: React.ReactNode;
className?: string;
}
export function AspectRatio16x9({ children, className }: AspectRatioProps) {
return (
<div className={`relative aspect-video overflow-hidden rounded-lg ${className ?? ""}`}>
{children}
</div>
);
}
// Usage
<AspectRatio16x9>
<img src="/hero.jpg" alt="Hero" className="h-full w-full object-cover" />
</AspectRatio16x9>Tailwind's aspect-video utility applies aspect-ratio: 16 / 9 natively. The overflow-hidden with rounded-lg clips the child content to rounded corners. Children should use h-full w-full object-cover to fill the frame without distortion.
4:3 Ratio
interface AspectRatioProps {
children: React.ReactNode;
className?: string;
}
export function AspectRatio4x3({ children, className }: AspectRatioProps) {
return (
<div className={`relative overflow-hidden rounded-lg ${className ?? ""}`} style={{ aspectRatio: "4 / 3" }}>
{children}
</div>
);
}
// Usage
<AspectRatio4x3>
<img src="/photo.jpg" alt="Photo" className="h-full w-full object-cover" />
</AspectRatio4x3>Tailwind does not ship a built-in aspect-4/3 class by default, so the inline aspectRatio style is the cleanest solution without extending the config. The 4:3 ratio suits photographs, product images, and traditional display content.
1:1 Square
interface AspectRatioProps {
children: React.ReactNode;
className?: string;
}
export function AspectRatioSquare({ children, className }: AspectRatioProps) {
return (
<div className={`relative aspect-square overflow-hidden rounded-lg ${className ?? ""}`}>
{children}
</div>
);
}
// Usage
<AspectRatioSquare className="w-24">
<img src="/avatar.jpg" alt="User avatar" className="h-full w-full object-cover" />
</AspectRatioSquare>Uses Tailwind's aspect-square for a 1:1 ratio. Ideal for profile images, product thumbnails in square grids, and social media post previews. Set a width on the outer container and the height follows automatically.
With Image Fill (Next.js)
import Image from "next/image";
interface AspectRatioImageProps {
src: string;
alt: string;
ratio?: number;
className?: string;
}
export function AspectRatioImage({
src,
alt,
ratio = 16 / 9,
className,
}: AspectRatioImageProps) {
return (
<div
className={`relative overflow-hidden rounded-lg ${className ?? ""}`}
style={{ aspectRatio: String(ratio) }}
>
<Image
src={src}
alt={alt}
fill
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover"
/>
</div>
);
}
// Usage
<AspectRatioImage src="/banner.jpg" alt="Banner" ratio={21 / 9} />Combines the aspect ratio container with the Next.js Image component in fill mode. The sizes prop is critical for performance -- it tells the browser which image size to download at each viewport width.
With Video Embed
interface AspectRatioVideoProps {
src: string;
title: string;
className?: string;
}
export function AspectRatioVideo({ src, title, className }: AspectRatioVideoProps) {
return (
<div className={`relative aspect-video overflow-hidden rounded-lg ${className ?? ""}`}>
<iframe
src={src}
title={title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="absolute inset-0 h-full w-full border-0"
/>
</div>
);
}
// Usage
<AspectRatioVideo
src="https://www.youtube.com/embed/dQw4w9WgXcQ"
title="Video tutorial"
/>The iframe is positioned absolutely inside the aspect ratio container so it stretches to fill the exact dimensions. The border-0 removes the default iframe border. Always include a descriptive title for accessibility.
Custom Ratio
interface AspectRatioProps {
ratio: number;
children: React.ReactNode;
className?: string;
}
export function AspectRatio({ ratio, children, className }: AspectRatioProps) {
return (
<div
className={`relative overflow-hidden ${className ?? ""}`}
style={{ aspectRatio: String(ratio) }}
>
{children}
</div>
);
}
// Usage examples
<AspectRatio ratio={21 / 9} className="rounded-lg">
<img src="/ultrawide.jpg" alt="Ultrawide" className="h-full w-full object-cover" />
</AspectRatio>
<AspectRatio ratio={3 / 4} className="rounded-lg">
<img src="/portrait.jpg" alt="Portrait" className="h-full w-full object-cover" />
</AspectRatio>Accepts any numeric ratio for non-standard dimensions. The ratio prop is a simple division (width / height), making it intuitive: 21/9 for ultrawide, 3/4 for portrait, 2/1 for a wide banner.
Complex Implementation
import { forwardRef, type CSSProperties } from "react";
import Image from "next/image";
type PresetRatio = "square" | "video" | "photo" | "portrait" | "ultrawide";
interface AspectRatioProps {
ratio?: number | PresetRatio;
children: React.ReactNode;
maxHeight?: number;
className?: string;
style?: CSSProperties;
}
const presetRatios: Record<PresetRatio, number> = {
square: 1,
video: 16 / 9,
photo: 4 / 3,
portrait: 3 / 4,
ultrawide: 21 / 9,
};
function resolveRatio(ratio: number | PresetRatio): number {
return typeof ratio === "string" ? presetRatios[ratio] : ratio;
}
export const AspectRatio = forwardRef<HTMLDivElement, AspectRatioProps>(
function AspectRatio({ ratio = "video", children, maxHeight, className, style }, ref) {
const numericRatio = resolveRatio(ratio);
return (
<div
ref={ref}
className={`relative overflow-hidden ${className ?? ""}`}
style={{
aspectRatio: String(numericRatio),
maxHeight: maxHeight ? `${maxHeight}px` : undefined,
...style,
}}
>
{children}
</div>
);
}
);
// Companion component for common image-in-ratio pattern
interface AspectRatioImageProps {
ratio?: number | PresetRatio;
src: string;
alt: string;
priority?: boolean;
sizes?: string;
maxHeight?: number;
className?: string;
imageClassName?: string;
}
export const AspectImage = forwardRef<HTMLDivElement, AspectRatioImageProps>(
function AspectImage(
{
ratio = "video",
src,
alt,
priority = false,
sizes = "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw",
maxHeight,
className,
imageClassName,
},
ref
) {
return (
<AspectRatio
ref={ref}
ratio={ratio}
maxHeight={maxHeight}
className={`bg-gray-100 ${className ?? ""}`}
>
<Image
src={src}
alt={alt}
fill
priority={priority}
sizes={sizes}
className={`object-cover ${imageClassName ?? ""}`}
/>
</AspectRatio>
);
}
);
// Usage
function Gallery() {
const images = [
{ src: "/img1.jpg", alt: "Mountain landscape" },
{ src: "/img2.jpg", alt: "City skyline" },
{ src: "/img3.jpg", alt: "Ocean sunset" },
];
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{images.map((img) => (
<AspectImage
key={img.src}
ratio="photo"
src={img.src}
alt={img.alt}
className="rounded-xl"
/>
))}
</div>
);
}Key aspects:
- Preset ratio names -- string aliases like
"video","photo", and"portrait"eliminate the need to remember numeric values and improve code readability at the call site. - Numeric fallback -- custom numeric ratios are still supported alongside presets, so edge cases like
2.35(cinemascope) are covered without extending the preset map. - maxHeight constraint -- prevents the aspect ratio container from growing too tall on wide screens. When the max height is reached, the width becomes the constrained dimension instead.
- Companion AspectImage -- a separate component wraps the common pattern of Next.js
Imageinside an aspect ratio box, providing sensiblesizesdefaults and a gray placeholder background. - forwardRef on both components -- refs flow through to the DOM so intersection observers, animation libraries, or scroll-linked effects can attach to the container.
- Background color placeholder -- the
bg-gray-100onAspectImageprovides a visible skeleton area while the image loads, preventing an invisible gap before the first paint.
Gotchas
-
aspect-ratioCSS property not supported in older Safari -- Safari versions before 15 do not support the nativeaspect-ratioproperty. If you need to support those browsers, use the padding-bottom hack instead. -
Next.js
Imagewithfillrequires a positioned parent -- theImagecomponent infillmode usesposition: absolute, so the container must haveposition: relative. Forgetting this causes the image to break out of the container. -
object-covervsobject-containmismatch -- usingobject-covercrops the image to fill the frame, whileobject-containletterboxes it. Choosing the wrong one either cuts off important content or leaves ugly gaps. -
Layout shift when ratio is loaded dynamically -- if the ratio comes from a CMS or API and is not known at build time, the container renders with no height until JavaScript hydrates. Include the ratio in server-rendered HTML or use a fallback ratio.
-
Missing
sizesprop on Next.js Image -- withoutsizes, Next.js defaults to100vw, causing the browser to download an image much larger than needed on small viewports. Always calculate realisticsizesbreakpoints. -
Percentage-based padding trick and flexbox conflict -- the
padding-bottompercentage trick calculates relative to the parent width, but inside a flex column the reference width may be unexpected. The nativeaspect-ratioCSS property avoids this issue entirely.