Components
Next

Ratio Preserver

A component that maintains aspect ratio and scales content to fit any viewport size while preserving proportions.

Features

  • Responsive scaling: Automatically scales content to fit available space
  • Aspect ratio preservation: Maintains exact width/height proportions
  • Viewport adaptation: Works seamlessly across all device sizes
  • Transform-based scaling: Uses CSS transforms for smooth scaling
  • Context-aware: Provides scaling information to child components

Installation

Copy and paste the following code into your project.

components/ui/ratio-preserver.tsx
'use client';

import { cn } from '@kit/shared';
import { Slot } from '@kit/ui/slot';
import * as React from 'react';

/* -------------------------------------------------------------------------------------------------
 * RatioPreserver Context
 * -----------------------------------------------------------------------------------------------*/

interface RatioPreserverContextValue {
    scale: number;
    contentWidth: number;
    contentHeight: number;
}

const RatioPreserverContext = React.createContext<RatioPreserverContextValue | undefined>(undefined);

const useRatioPreserverContext = () => {
    const context = React.useContext(RatioPreserverContext);
    if (!context) {
        throw new Error('useRatioPreserverContext must be used within RatioPreserver');
    }
    return context;
};

/* -------------------------------------------------------------------------------------------------
 * RatioPreserver Root
 * -----------------------------------------------------------------------------------------------*/

export interface RatioPreserverProps {
    width: number;
    height: number;
    children: React.ReactNode;
    className?: string;
    asChild?: boolean
}

export function RatioPreserver({ width, height, children, className, asChild = false }: RatioPreserverProps) {
    const containerRef = React.useRef<HTMLDivElement>(null);
    const [scale, setScale] = React.useState(1);
    const Comp = asChild ? Slot : 'div'

    React.useEffect(() => {
        const container = containerRef.current;
        if (!container) return;

        const updateScale = () => {
            const containerHeight = container.clientHeight;
            const containerWidth = container.clientWidth;

            // Guard against zero-sized container
            if (containerWidth === 0 || width === 0 || height === 0) {
                setScale(1);
                return;
            }

            // Calculate scale based on available width only
            // Container height will be set to match scaled content height
            const widthScale = containerWidth / width;
            const heightScale = containerHeight / height;

            setScale(Math.min(widthScale, heightScale));
        };

        // Use requestAnimationFrame to ensure layout is complete
        const rafId = requestAnimationFrame(() => {
            updateScale();
        });

        // Add window resize listener as backup
        const handleWindowResize = () => {
            requestAnimationFrame(updateScale);
        };
        window.addEventListener('resize', handleWindowResize);

        updateScale();

        // Observe container size changes
        // const resizeObserver = new ResizeObserver(() => {
        //     requestAnimationFrame(updateScale);
        // });
        // resizeObserver.observe(container);

        return () => {
            // resizeObserver.disconnect();
            cancelAnimationFrame(rafId);
            window.removeEventListener('resize', handleWindowResize);
        };
    }, [width, height]);

    const value = React.useMemo(
        () => ({
            scale,
            contentWidth: width,
            contentHeight: height,
        }),
        [scale, width, height]
    );

    return (
        <RatioPreserverContext.Provider value={value}>
            <Comp
                ref={containerRef}
                className={cn('relative', className)}
                data-slot="ratio-preserver-container"
                style={{
                    paddingTop: `${(height / width) * 100}%`,
                }}
            >
                {children}
            </Comp>
        </RatioPreserverContext.Provider>
    );
}

/* -------------------------------------------------------------------------------------------------
 * RatioPreserverContent
 * -----------------------------------------------------------------------------------------------*/

export interface RatioPreserverContentProps {
    children: React.ReactNode;
    className?: string;
}

export const RatioPreserverContent = React.forwardRef<HTMLDivElement, RatioPreserverContentProps>(
    ({ children, className }, ref) => {
        const { scale, contentWidth, contentHeight } = useRatioPreserverContext();

        return (
            <div
                ref={ref}
                className={cn('absolute', className)}
                data-slot="ratio-preserver-content"
                style={{
                    width: contentWidth,
                    height: contentHeight,
                    top: '50%',
                    left: '50%',
                    transform: `translate(-50%, -50%) scale(${scale})`,
                    transformOrigin: 'center center',
                }}
            >
                {children}
            </div>
        );
    }
);

RatioPreserverContent.displayName = 'RatioPreserverContent';

Update the import paths to match your project setup.

Usage

import { RatioPreserver, RatioPreserverContent } from '@kit/ui/ratio-preserver';
<RatioPreserver width={720} height={1080}>
    <RatioPreserverContent>
        <img 
            src="https://picsum.photos/720/1080" 
            alt="Example" 
            className="w-full h-full object-cover"
        />
    </RatioPreserverContent>
</RatioPreserver>

Examples

Device Mockup Integration

Perfect integration with MutableDeviceMockupRoot for responsive device previews:

How It Works

The RatioPreserver component works by:

  1. Setting container aspect ratio using padding-top percentage
  2. Calculating optimal scale based on available width and height
  3. Applying CSS transform to scale content proportionally
  4. Centering content using absolute positioning and transforms

The scaling calculation ensures content fits within the container while maintaining the exact aspect ratio specified.

API Reference

RatioPreserver

PropTypeDefault
width*
number
height*
number
children*
React.ReactNode
className
string
asChild
boolean

RatioPreserverContent

PropTypeDefault
children*
React.ReactNode
className
string

Accessibility

  • The component preserves all accessibility features of its children
  • Scaling does not affect screen reader navigation
  • Focus management remains intact during scaling
  • Interactive elements maintain their functionality

Performance

  • Uses CSS transforms for hardware-accelerated scaling
  • Optimized with requestAnimationFrame for smooth updates
  • Minimal re-renders with efficient context usage
  • Scales content without affecting layout calculations

How is this guide?

Last updated on 11/26/2025