Image

A figure with a caption and a zoom sized from the picture's own proportions

Click or tap to enlarge
import { Image } from "@/components/ui/image";
export default function ImageDemo() {
return (
<div className="w-full max-w-[560px]">
<Image
src="/images/editor-embed-sample.webp"
alt="A painted battle scene of knights on horseback in red, green and yellow"
width={1200}
height={672}
caption="Click or tap to enlarge"
/>
</div>
);
}

Installation

npx shadcn@latest add https://www.deltacomponents.dev/r/image.json

Usage

1import { Image } from "@/components/ui/image";
2
3<Image src="/painting.jpg" alt="A battle scene" width={1200} height={672} />

The name matches next/image's conventional import, so a file reaching for both aliases one of them:

1import NextImage from "next/image";
2import { Image } from "@/components/ui/image";

Zoom

Clicking or tapping the image opens it in a native <dialog>. Escape or a click anywhere inside closes it and returns focus to the image. So does scrolling the page: a dialog blocks clicks on what is behind it, not scrolling, and a reader who scrolls has moved on. The enlarged view is sized from the picture's own proportions: full width on a phone, whichever edge binds first — top and bottom for a landscape photo, the sides for a portrait one — on a wider screen.

import { Image } from "@/components/ui/image";
export default function ImageZoom() {
return (
<div className="w-full max-w-[320px]">
<Image
src="/images/image-zoom-sample.webp"
alt="A painting of sheep resting on an orange and green hillside among open laptops, with a figure in orange working on one under a blue sky"
width={560}
height={748}
/>
</div>
);
}

On a phone, two fingers zoom the enlarged picture about the point between them and one finger moves it around once zoomed; a tap on the zoomed picture doesn't close the view, the backdrop or a close control does. The page under the view stays put on a phone; before it is zoomed, one finger swiped anywhere closes the view instead.

Pass zoomable={false} for a plain figure with no button and no dialog.

Caption

caption puts a muted, centred line under the picture. For anything more — a left-aligned credit, a link, your own classes — write it as an ImageCaption child instead. It is the same element either way, and it goes under the picture, not into the enlarged view like the other children.

The Battle of Crécy, from a fifteenth-century manuscript. Wikipedia
import { Image, ImageCaption } from "@/components/ui/image";
export default function ImageCaptionDemo() {
return (
<div className="w-full max-w-[560px]">
<Image
src="/images/editor-embed-sample.webp"
alt="A painted battle scene of knights on horseback in red, green and yellow"
width={1200}
height={672}
>
<ImageCaption className="text-left">
The Battle of Crécy, from a fifteenth-century manuscript.{" "}
<a href="https://en.wikipedia.org/wiki/Battle_of_Cr%C3%A9cy" className="underline underline-offset-2">
Wikipedia
</a>
</ImageCaption>
</Image>
</div>
);
}

Close control

Escape and a click anywhere already close the enlarged view, but neither is visible, and a reader on a phone has no Escape key. Whatever you put inside Image is drawn in the enlarged view, over the picture, and ImageClose is a holder for a button there: pinned to the top right corner of the screen, in the same place for every image. The button needs no handler — ImageClose closes the view on any click inside it. Focus goes to the view itself when it opens, so a tap never leaves a focus ring on the button, and one Tab reaches it. A Button inside comes out as a pill: ImageClose sets --radius-button, which your own className still overrides.

Enlarge it: only the button in the top right corner, or Escape, closes it.
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Image, ImageClose } from "@/components/ui/image";
export default function ImageCloseControl() {
return (
<div className="w-full max-w-[320px]">
<Image
src="/images/image-zoom-sample.webp"
alt="A painting of sheep resting on an orange and green hillside among open laptops, with a figure in orange working on one under a blue sky"
width={560}
height={748}
caption="Enlarge it: only the button in the top right corner, or Escape, closes it."
dismissible={false}
>
<ImageClose>
{/* icon-xl is the 48px touch target; a mouse doesn't need it, so
a fine pointer steps the button and its glyph down one size. */}
<Button
variant="secondary"
size="icon-xl"
aria-label="Close"
className="pointer-fine:size-[var(--control-icon-box-lg,40px)] pointer-fine:[&_svg]:size-[var(--control-icon-glyph,16px)]"
>
<X />
</Button>
</ImageClose>
</Image>
</div>
);
}

The demo also turns dismissible off, which is when a close control stops being an affordance and becomes the control: a click on the picture or the backdrop, a swipe on a phone and a scroll on a desktop no longer close the view, and the cursor stops promising they will. Escape still does, always — a view that cannot be left from the keyboard is a trap, not a mode. Reach for it when the view carries controls of its own and a tap on the picture must not be a tap out of it; leave it on, the default, for a plain enlargement, where anywhere-to-close is what people expect.

The secondary button is the one to reach for. It has a fill of its own, so the icon stays legible over any picture; tertiary and ghost are transparent and would disappear into a busy one, and primary competes with the image.

Dimensions

width and height reserve the image's space before the file arrives, so nothing shifts once it loads, and tell the component its proportions on the server. Leave them off and it measures the picture itself once it is on the page — one render later, and only after the browser has it.

Bleed

A landscape image can run past its column to the edge of the screen; a portrait one never does, since edge to edge it would run taller than the screen it's on. bleed (below the md breakpoint) or bleed="always" turns it on; a portrait image, or one whose ratio isn't known yet, ignores either.

The distance to the edge comes from --image-bleed-gutter, a CSS variable set once on the surrounding text column (default 1rem). To reach the window's edge rather than the column's on a wide screen, set the gutter from the viewport — for a column centred at 680px, that's calc((100vw - 680px) / 2) — and clip the overshoot on an ancestor, since 100vw includes the scrollbar.

The example below isn't inside a ComponentPreview, on purpose: that frame has its own border and padding, so nothing inside it can reach the actual edge of the screen.

On a phone this runs to both edges of the screen. On a wider one it sits in the column.
1<div className="[--image-bleed-gutter:1.25rem]">
2 <Image
3 src="/painting.jpg"
4 alt="A painted battle scene of knights on horseback in red, green and yellow"
5 width={1200}
6 height={672}
7 bleed
8 caption="On a phone this runs to both edges of the screen. On a wider one it sits in the column."
9 />
10</div>

API Reference

Image

Any other <img> attribute passes through to the thumbnail.

ImageCaption

Any other <figcaption> attribute passes through.

ImageClose

Any other <span> attribute passes through.

Accessibility

The trigger is a real button, labelled from the alt text (Enlarge: …), so a screen reader announces what it's about to open. The dialog is labelled too and closes on Escape. alt is required by the type — there is no prop-level way to forget it, only the deliberate empty string for a decorative image.