组件
图片预览 Image Preview
点击缩略图进入沉浸式大图预览,并提供缩放、旋转与下载操作。
基础示例
Loading…
pnpm dlx wui@latest add @wui/image-preview"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import {
DownloadIcon,
MinusIcon,
PlusIcon,
RotateCcwIcon,
RotateCwIcon,
XIcon,
} from "lucide-react"
import {
AnimatePresence,
motion,
useMotionValue,
useReducedMotion,
} from "motion/react"
import { cn } from "@/lib/utils"
export interface ImagePreviewProps
extends Omit<React.ComponentProps<"button">, "children"> {
/** Image URL used by both the thumbnail and full-size preview. */
src: string
/** Accessible description of the image. */
alt: string
/** Optional higher-resolution URL used only in the preview. */
previewSrc?: string
/** Caption shown below the expanded image. */
caption?: React.ReactNode
/** Controlled open state. */
open?: boolean
/** Initial open state when uncontrolled. @default false */
defaultOpen?: boolean
/** Called whenever the preview opens or closes. */
onOpenChange?: (open: boolean) => void
/** Smallest available zoom level. @default 0.5 */
minZoom?: number
/** Largest available zoom level. @default 3 */
maxZoom?: number
/** Amount added or removed by each zoom action. @default 0.25 */
zoomStep?: number
/** Show zoom, rotate, reset, and optional download actions. @default true */
showToolbar?: boolean
/** Download filename. Supplying it adds a download action. */
downloadName?: string
/** Classes applied to the thumbnail image. */
thumbnailClassName?: string
/** Classes applied to the expanded image. */
previewClassName?: string
}
/** An image thumbnail that opens into a focused, transformable lightbox. */
function ImagePreview({
src,
alt,
previewSrc,
caption,
open: openProp,
defaultOpen = false,
onOpenChange,
minZoom = 0.5,
maxZoom = 3,
zoomStep = 0.25,
showToolbar = true,
downloadName,
className,
thumbnailClassName,
previewClassName,
...props
}: ImagePreviewProps) {
const reduceMotion = useReducedMotion()
const [internalOpen, setInternalOpen] = React.useState(defaultOpen)
const [zoom, setZoom] = React.useState(1)
const [rotation, setRotation] = React.useState(0)
const viewportRef = React.useRef<HTMLDivElement>(null)
const imageX = useMotionValue(0)
const imageY = useMotionValue(0)
const open = openProp ?? internalOpen
const fullSizeSrc = previewSrc ?? src
function handleOpenChange(next: boolean) {
if (openProp === undefined) setInternalOpen(next)
if (!next) {
setZoom(1)
setRotation(0)
imageX.set(0)
imageY.set(0)
}
onOpenChange?.(next)
}
function changeZoom(amount: number) {
setZoom((current) => {
const next = Math.min(maxZoom, Math.max(minZoom, current + amount))
if (next <= 1) {
imageX.set(0)
imageY.set(0)
}
return next
})
}
function resetTransform() {
setZoom(1)
setRotation(0)
imageX.set(0)
imageY.set(0)
}
function handleDownload() {
const anchor = document.createElement("a")
anchor.href = fullSizeSrc
anchor.download = downloadName ?? ""
anchor.click()
}
function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (event.key === "+" || event.key === "=") changeZoom(zoomStep)
if (event.key === "-") changeZoom(-zoomStep)
if (event.key === "0") resetTransform()
}
return (
<DialogPrimitive.Root open={open} onOpenChange={handleOpenChange}>
<DialogPrimitive.Trigger asChild>
<button
type="button"
data-slot="image-preview-trigger"
aria-label={`Preview ${alt}`}
className={cn(
"group relative block overflow-hidden rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40",
className
)}
{...props}
>
<img
data-slot="image-preview-thumbnail"
src={src}
alt={alt}
className={cn(
"block size-full object-cover transition-transform duration-300 group-hover:scale-[1.02] motion-reduce:transition-none",
thumbnailClassName
)}
/>
</button>
</DialogPrimitive.Trigger>
<AnimatePresence>
{open ? (
<DialogPrimitive.Portal forceMount>
<DialogPrimitive.Overlay asChild forceMount>
<motion.div
data-slot="image-preview-overlay"
className="fixed inset-0 z-50 bg-overlay/90"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0 : 0.18 }}
/>
</DialogPrimitive.Overlay>
<DialogPrimitive.Content
forceMount
data-slot="image-preview-content"
className="fixed inset-0 z-50 flex flex-col overflow-hidden outline-none"
onKeyDown={handleKeyDown}
>
<DialogPrimitive.Title className="sr-only">
Image preview: {alt}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="sr-only">
{caption ?? "Expanded image preview with transform controls."}
</DialogPrimitive.Description>
<div className="relative z-10 flex h-14 shrink-0 items-center justify-end gap-1 px-3">
{showToolbar ? (
<div
data-slot="image-preview-toolbar"
className="flex items-center gap-1 rounded-md border bg-background p-1 shadow-sm"
>
<PreviewAction
label="Zoom out"
disabled={zoom <= minZoom}
onClick={() => changeZoom(-zoomStep)}
>
<MinusIcon />
</PreviewAction>
<span className="min-w-12 px-1 text-center text-xs tabular-nums text-muted-foreground">
{Math.round(zoom * 100)}%
</span>
<PreviewAction
label="Zoom in"
disabled={zoom >= maxZoom}
onClick={() => changeZoom(zoomStep)}
>
<PlusIcon />
</PreviewAction>
<span className="mx-1 h-5 w-px bg-border" />
<PreviewAction
label="Rotate counterclockwise"
onClick={() => setRotation((current) => current - 90)}
>
<RotateCcwIcon />
</PreviewAction>
<PreviewAction
label="Rotate clockwise"
onClick={() => setRotation((current) => current + 90)}
>
<RotateCwIcon />
</PreviewAction>
<PreviewAction label="Reset image" onClick={resetTransform}>
<span className="text-[11px] font-semibold">1:1</span>
</PreviewAction>
{downloadName ? (
<PreviewAction label="Download image" onClick={handleDownload}>
<DownloadIcon />
</PreviewAction>
) : null}
</div>
) : null}
<DialogPrimitive.Close asChild>
<PreviewAction label="Close preview">
<XIcon />
</PreviewAction>
</DialogPrimitive.Close>
</div>
<div
ref={viewportRef}
className="flex min-h-0 flex-1 items-center justify-center overflow-hidden p-6 pt-2"
onPointerDown={(event) => {
if (event.target === event.currentTarget) handleOpenChange(false)
}}
>
<motion.img
data-slot="image-preview-image"
src={fullSizeSrc}
alt={alt}
className={cn(
"max-h-full max-w-full select-none object-contain shadow-lg",
zoom > 1 ? "cursor-grab active:cursor-grabbing" : "cursor-default",
previewClassName
)}
style={{ x: imageX, y: imageY }}
drag={zoom > 1}
dragConstraints={viewportRef}
dragElastic={0.08}
dragMomentum={false}
initial={reduceMotion ? false : { opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: zoom, rotate: rotation }}
exit={reduceMotion ? undefined : { opacity: 0, scale: 0.96 }}
transition={
reduceMotion
? { duration: 0 }
: { type: "spring", stiffness: 360, damping: 32, mass: 0.7 }
}
/>
</div>
{caption ? (
<div
data-slot="image-preview-caption"
className="relative z-10 shrink-0 px-6 pb-5 text-center text-sm text-background"
>
{caption}
</div>
) : null}
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
) : null}
</AnimatePresence>
</DialogPrimitive.Root>
)
}
function PreviewAction({
label,
className,
children,
...props
}: React.ComponentProps<"button"> & { label: string }) {
return (
<button
type="button"
data-slot="image-preview-action"
aria-label={label}
title={label}
className={cn(
"inline-flex size-8 items-center justify-center rounded-sm bg-background text-foreground outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/35 disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-4",
className
)}
{...props}
>
{children}
</button>
)
}
export { ImagePreview }
组件作用
ImagePreview 用于在不离开当前页面的情况下查看图片细节。缩略图本身是可聚焦按钮,全屏预览通过 Dialog 管理焦点、Escape 关闭和无障碍标题。它适合相册、附件、设计稿与商品图;需要编辑、裁剪或批注时,应使用专门的图片编辑器。
组件属性
| Prop | Type | Default | Description |
|---|---|---|---|
| src * | string | — | Image URL used by both the thumbnail and full-size preview. |
| alt * | string | — | Accessible description of the image. |
| previewSrc | string | — | Optional higher-resolution URL used only in the preview. |
| caption | ReactNode | — | Caption shown below the expanded image. |
| open | boolean | — | Controlled open state. |
| defaultOpen | boolean | false | Initial open state when uncontrolled. |
| onOpenChange | ((open: boolean) => void) | — | Called whenever the preview opens or closes. |
| minZoom | number | 0.5 | Smallest available zoom level. |
| maxZoom | number | 3 | Largest available zoom level. |
| zoomStep | number | 0.25 | Amount added or removed by each zoom action. |
| showToolbar | boolean | true | Show zoom, rotate, reset, and optional download actions. |
| downloadName | string | — | Download filename. Supplying it adds a download action. |
| thumbnailClassName | string | — | Classes applied to the thumbnail image. |
| previewClassName | string | — | Classes applied to the expanded image. |
| disabled | boolean | — |
previewSrc 可提供单独的高清图片地址;未指定时沿用 src。通过 minZoom、maxZoom 和 zoomStep 调整缩放行为,通过 thumbnailClassName 与 previewClassName 分别控制两种图片状态。
事件
onOpenChange(open):预览打开或关闭时触发,支持受控与非受控状态。- 缩放按钮也可使用
+、-快捷键,按0重置缩放和旋转。 - Escape、背景点击和焦点管理会关闭或约束预览;放大后可拖动图片查看边缘细节。
- 其余按钮属性会透传给缩略图触发器。
拓展使用
简洁预览
Loading…
设置 showToolbar={false} 可隐藏变换操作,仅保留查看与关闭能力。传入 downloadName 时才会显示下载按钮,避免在不允许保存图片的场景中暴露无效操作。