Skip to content
Twilight React Playground
ثيم رائدaren

ImageModal

componentBeginnerserverbrowserlive demo

A ready-made Modal showing one image at full size with a loading placeholder; lazily loaded, so it needs a Suspense boundary.

import { ImageModal, ImageModalProps } from '@salla.sa/twilight-theme-engine/components/modal';

In plain words

Click a small picture, see it big: ImageModal is a Modal already filled with one image, an optional title and a close button. While the image downloads, a grey pulsing box holds its place.

Its code is downloaded separately, the first time the component renders. So put it inside React's <Suspense>: a component that shows a fallback (or nothing) while something inside it is still loading.

Signature

const ImageModal: React.LazyExoticComponent<(props: ImageModalProps) => ReactNode>

interface ImageModalProps {
  isOpen: boolean;
  onClose: () => void;
  src: string;        // nothing renders while it is empty
  alt?: string;       // default ''
  title?: string;     // shown in the header
  imgProps?: Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'alt'>;
}

Try it live

The store's six newest products. Click a picture to open it in ImageModal at full size.Try this: turn the title off: the header keeps its close button and an empty heading.
Storefront canvas · en · LTR

Loading products…

Controls
title
What a theme writes
import { Suspense, useCallback, useState } from 'react';
import { ImageModal } from '@salla.sa/twilight-theme-engine/components/modal';

export function ZoomableImage({ src, alt }: { src: string; alt: string }) {
  const [isOpen, setIsOpen] = useState(false);
  const close = useCallback(() => setIsOpen(false), []);

  return (
    <>
      <button type="button" onClick={() => setIsOpen(true)}>
        <img src={src} alt={alt} width={96} height={96} />
      </button>
      <Suspense fallback={null}>
        <ImageModal isOpen={isOpen} onClose={close} src={src} alt={alt} title={alt} />
      </Suspense>
    </>
  );
}

Example

app/components/ZoomableImage.tsx
import { Suspense, useCallback, useState } from 'react';
import { ImageModal } from '@salla.sa/twilight-theme-engine/components/modal';

export function ZoomableImage({ src, alt }: { src: string; alt: string }) {
  const [isOpen, setIsOpen] = useState(false);
  const close = useCallback(() => setIsOpen(false), []);

  return (
    <>
      <button type="button" onClick={() => setIsOpen(true)}>
        <img src={src} alt={alt} width={120} height={120} />
      </button>
      <Suspense fallback={null}>
        <ImageModal isOpen={isOpen} onClose={close} src={src} alt={alt} title={alt} />
      </Suspense>
    </>
  );
}

How it behaves

  • The export is React.lazy(() => import('./ImageModal')). The eager component is not exported under any name.

  • It renders <Modal size="lg" position="center"> with <Modal.Header onClose showCloseButton>{title}</Modal.Header> and a centered body. The image is max-w-full max-h-[70vh] object-contain.

  • The image is keyed by src, so opening a different picture shows the placeholder again until it loads. Returns null while src is empty, even with isOpen.

  • The engine uses it for the product gallery zoom (ProductGallery) and the tax certificate link in the footer.

  • Everything on the Modal page applies, including the stable onClose.

Gotchas

  • Because it is lazy, rendering it at all, even with isOpen={false}, starts the download and suspends. Without your own <Suspense fallback={null}> around it, the nearest boundary above (possibly the whole page) shows its fallback while the chunk loads.

  • imgProps is spread after the component's own onLoad and className (ImageModal.tsx). Passing imgProps.onLoad replaces the handler that reveals the image, so it stays hidden behind the placeholder; passing imgProps.className replaces the sizing and show/hide classes. Pass other attributes only, such as loading or decoding.

  • There is no error handling: an image that fails to load leaves the grey placeholder pulsing, with no message.

Related

Source and docs