CaisyImage React Component - Sample Implementation

Overview

This is a sample implementation of an advanced image component that works with Caisy assets. The component demonstrates how to build a sophisticated image renderer with focal point support, lazy loading, responsive image sizing, and smooth loading transitions with placeholder effects.

Implementation Features

This sample implementation includes:

  • Responsive Images: Demonstrates automatic generation of multiple image sizes for different breakpoints

  • Lazy Loading: Shows how to implement optional lazy loading with configurable modes

  • Placeholder Effects: Example of BlurHash and dominant color placeholder integration

  • Focal Point: Sample focal point support for optimal image cropping

  • Smooth Transitions: Implementation of fade-in animations during image loading

  • SVG Support: Example of special SVG image handling

  • Accessibility: Demonstrates proper alt text and title attribute usage

Component Interface

interface IFancyImage {
    img: IGenAsset;
    responsiveFactor?: IResponseOptionalValue<number>;
    responsiveAspectRatio?: IResponseOptionalValue<number>;
    responsiveMaxWidth?: IResponseOptionalValue<number>;
    lazyload?: boolean;
    onClick?: MouseEventHandler<HTMLDivElement>;
    onLoad?: ReactEventHandler<HTMLImageElement>;
    inspectProps?: any;
}

Prop Descriptions

  • img (required): The image asset object containing src, dimensions, and metadata

  • responsiveFactor: Controls how much screen space the image occupies (0-1), defaults to 1 (full width)

  • responsiveAspectRatio: Override default aspect ratio for each breakpoint

  • responsiveMaxWidth: Maximum width in pixels for each breakpoint

  • lazyload: Enable/disable lazy loading (default: true)

  • onClick: Click handler for the image container

  • onLoad: Callback when image finishes loading

Type Definitions

// Image asset interface
export type IGenAsset = {
  __typename: 'Asset';
  _meta?: Maybe<IGenCaisyDocument_Meta>;
  author?: Maybe<string>;
  blurHash?: Maybe<string>;
  copyright?: Maybe<string>;
  description?: Maybe<string>;
  dominantColor?: Maybe<string>;
  height?: Maybe<number>;
  id?: Maybe<string>;
  keywords?: Maybe<string>;
  originalName?: Maybe<string>;
  originType?: Maybe<string>;
  src?: Maybe<string>;
  subtitle?: Maybe<string>;
  title?: Maybe<string>;
  width?: Maybe<number>;
};

// Responsive value interface for breakpoint-specific values
export interface IResponseOptionalValue<T> {
    bronze?: T;
    silver?: T;
    gold?: T;
    platinum?: T;
    diamond?: T;
    master?: T;
}

Full Sample Code

import { FC, MouseEventHandler, ReactEventHandler, useCallback, useEffect, useRef, useState } from "react";
import styled, { css, keyframes } from "styled-components";
import { blurhashToCssGradient } from "blurhash-to-css-gradient";

// Constants for breakpoints (example values)
const BREAKPOINTS = {
    SILVER: 768,
    GOLD: 1024,
    PLATINUM: 1440,
    DIAMOND: 1920,
    MASTER: 2560,
    CHALLENGER: 3840
};

// Media query helpers
const MIN_SILVER = (styles: any) => css`@media (min-width: ${BREAKPOINTS.SILVER}px) { ${styles} }`;
const MIN_GOLD = (styles: any) => css`@media (min-width: ${BREAKPOINTS.GOLD}px) { ${styles} }`;
const MIN_PLATINUM = (styles: any) => css`@media (min-width: ${BREAKPOINTS.PLATINUM}px) { ${styles} }`;
const MIN_DIAMOND = (styles: any) => css`@media (min-width: ${BREAKPOINTS.DIAMOND}px) { ${styles} }`;
const MIN_MASTER = (styles: any) => css`@media (min-width: ${BREAKPOINTS.MASTER}px) { ${styles} }`;

// Types
export type IGenAsset = {
  __typename: 'Asset';
  author?: string;
  blurHash?: string;
  copyright?: string;
  description?: string;
  dominantColor?: string;
  height?: number;
  id?: string;
  keywords?: string;
  originalName?: string;
  originType?: string;
  src?: string;
  subtitle?: string;
  title?: string;
  width?: number;
};

export interface IResponseOptionalValue<T> {
    bronze?: T;
    silver?: T;
    gold?: T;
    platinum?: T;
    diamond?: T;
    master?: T;
}

export interface IFancyImage {
    img: IGenAsset;
    responsiveFactor?: IResponseOptionalValue<number>;
    responsiveAspectRatio?: IResponseOptionalValue<number>;
    responsiveMaxWidth?: IResponseOptionalValue<number>;
    lazyload?: boolean;
    onClick?: MouseEventHandler<HTMLDivElement>;
    onLoad?: ReactEventHandler<HTMLImageElement>;
    inspectProps?: any;
}

// Styled Components

// Skeleton/Placeholder with pulse animation
const pulseAnimation = keyframes`
  0% { opacity: 0.8; }
  50% { opacity: 1; }
  100% { opacity: 0.8; }
`;

const SFancyImageSkeleton = styled.div<{ background: string }>`
  width: 100%;
  height: 100%;
  background: ${({ background }) => background};
  animation: ${pulseAnimation} 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
`;

// Image with fade-in transition
const SFancyImageImg = styled.img<{ isLoaded?: boolean }>`
    transition: opacity 0.3s ease;
    opacity: ${(props) => (props.isLoaded ? 1 : 0)};
    object-fit: cover;
    height: 100%;
    width: 100%;
    display: block;
`;

// Aspect ratio container
interface ISFancyImageAspectContainerProps {
    brozeAspectRatio: number;
    silverAspectRatio: number;
    goldAspectRatio: number;
    platinumAspectRatio: number;
    diamondAspectRatio: number;
    masterAspectRatio: number;
    hasLoaded: boolean;
}

const SFancyImageAspectContainer = styled.div<ISFancyImageAspectContainerProps>`
    position: relative;
    z-index: 0;
    width: 100%;
    
    > .absolute-layer {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
    }
    
    > .layer-z1 {
        z-index: 1;
        transition: opacity 0.3s ease-in-out;
        ${({ hasLoaded }) => hasLoaded && css`opacity: 0;`}
    }
    
    > .layer-z2 {
        z-index: 2;
    }

    :before {
        pointer-events: none;
        content: "";
        width: 100%;
        position: relative;
        display: block;
        padding-bottom: ${(props) => (1 / props.brozeAspectRatio) * 100}%;
    }

    ${MIN_SILVER(css`
        :before {
            padding-bottom: ${(props: ISFancyImageAspectContainerProps) => (1 / props.silverAspectRatio) * 100}%;
        }
    `)};
    
    ${MIN_GOLD(css`
        :before {
            padding-bottom: ${(props: ISFancyImageAspectContainerProps) => (1 / props.goldAspectRatio) * 100}%;
        }
    `)};
    
    ${MIN_PLATINUM(css`
        :before {
            padding-bottom: ${(props: ISFancyImageAspectContainerProps) => (1 / props.platinumAspectRatio) * 100}%;
        }
    `)};
    
    ${MIN_DIAMOND(css`
        :before {
            padding-bottom: ${(props: ISFancyImageAspectContainerProps) => (1 / props.diamondAspectRatio) * 100}%;
        }
    `)};
    
    ${MIN_MASTER(css`
        :before {
            padding-bottom: ${(props: ISFancyImageAspectContainerProps) => (1 / props.masterAspectRatio) * 100}%;
        }
    `)};
`;

// Utility function to calculate responsive image sizes
const calculateResponsiveImagesSizes = ({
    responsiveFactor,
    responsiveMaxWidth,
    responsiveAspectRatio,
    images,
}: {
    responsiveFactor?: IResponseOptionalValue<number>;
    responsiveMaxWidth?: IResponseOptionalValue<number>;
    responsiveAspectRatio?: IResponseOptionalValue<number>;
    images: IGenAsset[];
}) => {
    const img = images[0];
    const defaultAspectRatio = (img.width || 1) / (img.height || 1);
    
    const calculateSize = (breakpoint: keyof IResponseOptionalValue<number>, breakpointWidth: number) => {
        const factor = responsiveFactor?.[breakpoint] || 1;
        const maxWidth = responsiveMaxWidth?.[breakpoint] || breakpointWidth;
        const aspectRatio = responsiveAspectRatio?.[breakpoint] || defaultAspectRatio;
        
        const width = Math.min(breakpointWidth * factor, maxWidth);
        const height = width / aspectRatio;
        
        return { width: Math.round(width), height: Math.round(height) };
    };

    return {
        bronze: calculateSize('bronze', 480),
        silver: calculateSize('silver', BREAKPOINTS.SILVER),
        gold: calculateSize('gold', BREAKPOINTS.GOLD),
        platinum: calculateSize('platinum', BREAKPOINTS.PLATINUM),
        diamond: calculateSize('diamond', BREAKPOINTS.DIAMOND),
        master: calculateSize('master', BREAKPOINTS.MASTER),
    };
};

// Main Component
export const CaisyImage: FC<React.PropsWithChildren<ICaisyImage>> = ({
    img,
    responsiveFactor,
    responsiveAspectRatio,
    responsiveMaxWidth,
    lazyload = true,
    onClick,
    onLoad,
}) => {
    const loading = lazyload ? "lazy" : "eager";
    const [isImgLoaded, setIsImgLoaded] = useState(false);
    const imageRef = useRef<HTMLImageElement>(null);

    const handleOnLoad = useCallback(() => {
        setIsImgLoaded(true);
    }, [setIsImgLoaded]);

    useEffect(() => {
        if (!imageRef.current) {
            return;
        }

        if (imageRef.current.complete) {
            setIsImgLoaded(true);
            return;
        }
        imageRef.current.onload = handleOnLoad;
    }, [imageRef.current, handleOnLoad]);

    if (!img || !img.src) {
        return null;
    }

    const sizes = calculateResponsiveImagesSizes({
        responsiveFactor,
        responsiveMaxWidth,
        responsiveAspectRatio,
        images: [img],
    });

    const actualSrc: string = img.src;
    let _src = actualSrc;

    const hasSVG = _src.includes(".svg");

    if (!hasSVG) {
        _src = _src.includes("?") ? _src + "&" : _src + "?";
    }

    const srcSet = `${_src + `width=${sizes.bronze.width}&height=${sizes.bronze.height}`} ${BREAKPOINTS.SILVER - 1}w,
    ${_src + `width=${sizes.silver.width}&height=${sizes.silver.height}`} ${BREAKPOINTS.GOLD - 1}w,
    ${_src + `width=${sizes.gold.width}&height=${sizes.gold.height}`} ${BREAKPOINTS.PLATINUM - 1}w,
    ${_src + `width=${sizes.platinum.width}&height=${sizes.platinum.height}`} ${BREAKPOINTS.DIAMOND - 1}w,
    ${_src + `width=${sizes.diamond.width}&height=${sizes.diamond.height}`} ${BREAKPOINTS.MASTER - 1}w,
    ${_src + `width=${sizes.master.width}&height=${sizes.master.height}`} ${BREAKPOINTS.CHALLENGER - 1}w`;

    let placeholderBackground = "#e3e3e3e3";

    const { dominantColor, blurHash } = img;

    if (blurHash) {
        try {
            placeholderBackground = blurhashToCssGradient(blurHash);
        } catch {}
    } else {
        if (dominantColor) {
            if (dominantColor.startsWith("#")) {
                placeholderBackground = dominantColor;
            } else {
                placeholderBackground = `#${dominantColor}`;
            }
        }
    }

    return (
        <SFancyImageAspectContainer
            brozeAspectRatio={sizes.bronze.width / sizes.bronze.height}
            silverAspectRatio={sizes.silver.width / sizes.silver.height}
            goldAspectRatio={sizes.gold.width / sizes.gold.height}
            platinumAspectRatio={sizes.platinum.width / sizes.platinum.height}
            diamondAspectRatio={sizes.diamond.width / sizes.diamond.height}
            masterAspectRatio={sizes.master.width / sizes.master.height}
            onClick={onClick}
            hasLoaded={isImgLoaded}
        >
            <div className="absolute-layer layer-z1">
                <SFancyImageSkeleton background={placeholderBackground} />
            </div>
            <div className="absolute-layer layer-z2">
                <SFancyImageImg
                    ref={imageRef}
                    src={hasSVG ? img.src : `${_src}width=${sizes.bronze.width}&height=${sizes.bronze.height}`}
                    isLoaded={isImgLoaded}
                    srcSet={!hasSVG ? srcSet : ""}
                    sizes="100vw"
                    title={img.description || undefined}
                    alt={img.description || undefined}
                    loading={loading}
                    onLoad={(e) => {
                        handleOnLoad();
                        typeof onLoad === "function" && onLoad(e);
                    }}
                />
            </div>
        </SFancyImageAspectContainer>
    );
};

Example Usage

Basic Usage

<CaisyImage img={imageAsset} />

With Responsive Configuration

<CaisyImage 
    img={imageAsset}
    responsiveFactor={{
        bronze: 1,      // Full width on mobile
        silver: 0.5,    // Half width on tablet
        gold: 0.33      // Third width on desktop
    }}
    responsiveMaxWidth={{
        bronze: 480,
        silver: 400,
        gold: 300
    }}
/>

With Click Handler and Custom Loading

<CaisyImage 
    img={imageAsset}
    lazyload={false}
    onClick={() => console.log('Image clicked')}
    onLoad={() => console.log('Image loaded')}
/>

Implementation Details

This sample demonstrates several advanced techniques:

  • Shows how to generate responsive srcSet attributes for optimal performance across devices

  • Demonstrates BlurHash to CSS gradient conversion for seamless loading experiences

  • Illustrates special SVG handling without responsive transformations

  • Examples of layered architecture with absolute positioning for smooth transitions

  • Shows aspect ratio maintenance across breakpoints using CSS padding-bottom technique

  • Demonstrates integration patterns with Caisy CMS asset structure

Dependencies

  • react - Core React functionality

  • styled-components - CSS-in-JS styling

  • blurhash-to-css-gradient - BlurHash to CSS gradient conversion