import { Image } from "@/components/ui/image";export default function ImageDemo() {return (<div className="w-full max-w-[560px]"><Imagesrc="/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";23<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]"><Imagesrc="/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.
import { Image, ImageCaption } from "@/components/ui/image";export default function ImageCaptionDemo() {return (<div className="w-full max-w-[560px]"><Imagesrc="/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.
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]"><Imagesrc="/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, soa fine pointer steps the button and its glyph down one size. */}<Buttonvariant="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.
1<div className="[--image-bleed-gutter:1.25rem]">2 <Image3 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 bleed8 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.