Skip to content
goodtabs UI
BrowseBrowse components

Image zoom

Click an image to see it large. Keyboard reachable, Escape closes.

npx shadcn@latest add https://ui.goodtabs.co/r/image-zoom.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/image-zoom

A thumbnail that opens large on click or Enter.

A pair

Two thumbnails side by side, each zoomable.

Usage

import { ImageZoom } from "@/components/ui/image-zoom"

<ImageZoom src="/shot.jpg" alt="The settings page" maxWidth="320px" />

Props

PropTypeDefaultDescription
srcstringImage URL, used for the thumbnail and the large view.
altstringDescribes the image for people who cannot see it.
maxWidthstringConstrain the thumbnail, for example "320px".

Source

image-zoom/image-zoom.tsx
"use client";

import * as React from "react";
import { Dialog } from "radix-ui";
import { cn } from "@/lib/utils";

type ImageZoomProps = {
  src: string;
  /** Describe the image for people who cannot see it. */
  alt: string;
  /** Constrain the thumbnail, for example "320px". */
  maxWidth?: string;
  className?: string;
};

/**
 * Click or press Enter to open the image large over a dimmed page. Escape or
 * a click outside closes it. The fade is skipped under reduced motion.
 */
export function ImageZoom({ src, alt, maxWidth, className }: ImageZoomProps) {
  const [open, setOpen] = React.useState(false);
  return (
    <Dialog.Root open={open} onOpenChange={setOpen}>
      <Dialog.Trigger
        aria-label={`Zoom image: ${alt}`}
        className={cn(
          "block w-full cursor-zoom-in overflow-hidden rounded-lg border outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
          className,
        )}
        style={{ maxWidth }}
      >
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img src={src} alt="" loading="lazy" className="block aspect-3/2 w-full object-cover" />
      </Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 z-50 bg-black/70 duration-150 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 motion-reduce:animate-none motion-reduce:duration-0" />
        <Dialog.Content
          aria-describedby={undefined}
          className="fixed inset-0 z-50 grid cursor-zoom-out place-items-center p-4 outline-none duration-150 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 motion-reduce:animate-none motion-reduce:duration-0"
          onClick={() => setOpen(false)}
        >
          <Dialog.Title className="sr-only">{alt}</Dialog.Title>
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img src={src} alt={alt} className="max-h-full max-w-full rounded-lg border object-contain" />
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/image-zoom/prompt.md, or the Copy AI prompt button above.

When to use

  • Screenshots whose detail is lost at thumbnail size
  • Article images that deserve a closer look

When not to use

  • A gallery with next and previous, which needs a carousel
  • Images that are already shown full width

Agent notes

  • Client component built on the Radix dialog, so focus is trapped and restored
  • The trigger is a button with an accessible name; Escape or a click closes
  • The 150ms fade is removed under reduced motion
  • Uses the same src for the thumbnail and the large view