wui
组件

图片预览 Image Preview

点击缩略图进入沉浸式大图灯箱预览,支持平滑缩放、旋转、拖拽平移、多图切换与原图下载。

第三方依赖 · radix-ui第三方依赖 · lucide-react第三方依赖 · motion

基础用法

点击图片缩略图展开全屏灯箱,支持通过底部工具栏、滚轮、双击或快捷键缩放与旋转,变换均以弹簧动效过渡:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/image-preview
安装 Radix 原语与动效库依赖
pnpm add radix-ui motion lucide-react clsx tailwind-merge
复制组件源码到 components/ui/image-preview.tsx
components/ui/image-preview.tsx
"use client"

import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import {
  ChevronLeftIcon,
  ChevronRightIcon,
  DownloadIcon,
  MinusIcon,
  PlusIcon,
  RotateCcwIcon,
  RotateCwIcon,
  XIcon,
} from "lucide-react"
import {
  AnimatePresence,
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  type PanInfo,
} from "motion/react"

import { cn } from "@/lib/utils"

const EASE = [0.22, 1, 0.36, 1] as const
const SPRING = { type: "spring", stiffness: 520, damping: 38, mass: 0.7 } as const

type PreviewItem = {
  id: string
  src: string
  alt: string
  caption?: React.ReactNode
  downloadName?: string
}

type LightboxOptions = {
  minZoom: number
  maxZoom: number
  zoomStep: number
  showToolbar: boolean
  previewClassName?: string
}

type ImagePreviewGroupContextValue = {
  register: (item: PreviewItem) => () => void
  openItem: (id: string) => void
}

const ImagePreviewGroupContext =
  React.createContext<ImagePreviewGroupContextValue | null>(null)

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. Ignored inside ImagePreviewGroup. */
  open?: boolean
  /** Initial open state when uncontrolled. Ignored inside ImagePreviewGroup. @default false */
  defaultOpen?: boolean
  /** Called whenever the preview opens or closes. Ignored inside ImagePreviewGroup. */
  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,
  onClick,
  ...props
}: ImagePreviewProps) {
  const group = React.useContext(ImagePreviewGroupContext)
  const id = React.useId()
  const [internalOpen, setInternalOpen] = React.useState(defaultOpen)
  const open = openProp ?? internalOpen
  const fullSizeSrc = previewSrc ?? src

  React.useLayoutEffect(() => {
    if (!group) return
    return group.register({
      id,
      src: fullSizeSrc,
      alt,
      caption,
      downloadName,
    })
  }, [alt, caption, downloadName, fullSizeSrc, group, id])

  const trigger = (
    <button
      type="button"
      data-slot="image-preview-trigger"
      data-preview-id={id}
      aria-haspopup={group ? "dialog" : undefined}
      aria-label={`预览图片:${alt}`}
      className={cn(
        "group/preview relative block overflow-hidden rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40",
        className
      )}
      onClick={(event) => {
        onClick?.(event)
        if (group && !event.defaultPrevented) group.openItem(id)
      }}
      {...props}
    >
      <img
        data-slot="image-preview-thumbnail"
        src={src}
        alt={alt}
        className={cn(
          "block size-full object-cover transition-transform duration-300 ease-out group-hover/preview:scale-[1.03] motion-reduce:transition-none",
          thumbnailClassName
        )}
      />
    </button>
  )

  if (group) return trigger

  function handleOpenChange(next: boolean) {
    if (openProp === undefined) setInternalOpen(next)
    onOpenChange?.(next)
  }

  return (
    <DialogPrimitive.Root open={open} onOpenChange={handleOpenChange}>
      <DialogPrimitive.Trigger asChild>{trigger}</DialogPrimitive.Trigger>
      <ImagePreviewLightbox
        open={open}
        onOpenChange={handleOpenChange}
        items={[{ id, src: fullSizeSrc, alt, caption, downloadName }]}
        index={0}
        onIndexChange={() => {}}
        minZoom={minZoom}
        maxZoom={maxZoom}
        zoomStep={zoomStep}
        showToolbar={showToolbar}
        previewClassName={previewClassName}
      />
    </DialogPrimitive.Root>
  )
}

export interface ImagePreviewGroupProps extends React.ComponentProps<"div"> {
  /** Controlled open state of the shared lightbox. */
  open?: boolean
  /** Initial open state when uncontrolled. @default false */
  defaultOpen?: boolean
  /** Called whenever the shared lightbox opens or closes. */
  onOpenChange?: (open: boolean) => void
  /** Controlled index of the image shown in the lightbox. */
  index?: number
  /** Initial index when uncontrolled. @default 0 */
  defaultIndex?: number
  /** Called when the lightbox switches to another image. */
  onIndexChange?: (index: number) => 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
  /** Classes applied to the expanded image. */
  previewClassName?: string
}

/**
 * Groups several ImagePreview thumbnails into one lightbox with previous /
 * next navigation, a thumbnail strip, arrow keys, and swipe gestures.
 * Images are ordered by their position in the DOM.
 */
function ImagePreviewGroup({
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  index: indexProp,
  defaultIndex = 0,
  onIndexChange,
  minZoom = 0.5,
  maxZoom = 3,
  zoomStep = 0.25,
  showToolbar = true,
  previewClassName,
  children,
  ...props
}: ImagePreviewGroupProps) {
  const rootRef = React.useRef<HTMLDivElement>(null)
  const registry = React.useRef(new Map<string, PreviewItem>())
  const [items, setItems] = React.useState<PreviewItem[]>([])
  const [internalOpen, setInternalOpen] = React.useState(defaultOpen)
  const [internalIndex, setInternalIndex] = React.useState(defaultIndex)
  const open = openProp ?? internalOpen
  const index = indexProp ?? internalIndex

  const collectItems = React.useCallback(() => {
    const triggers = rootRef.current?.querySelectorAll<HTMLElement>(
      "[data-slot=image-preview-trigger][data-preview-id]"
    )
    const ordered = Array.from(triggers ?? [])
      .map((trigger) => registry.current.get(trigger.dataset.previewId ?? ""))
      .filter((item): item is PreviewItem => Boolean(item))
    setItems(ordered)
    return ordered
  }, [])

  const register = React.useCallback(
    (item: PreviewItem) => {
      registry.current.set(item.id, item)
      collectItems()
      return () => {
        registry.current.delete(item.id)
        collectItems()
      }
    },
    [collectItems]
  )

  const setIndex = React.useCallback(
    (next: number) => {
      if (indexProp === undefined) setInternalIndex(next)
      onIndexChange?.(next)
    },
    [indexProp, onIndexChange]
  )

  const setOpen = React.useCallback(
    (next: boolean) => {
      if (openProp === undefined) setInternalOpen(next)
      onOpenChange?.(next)
    },
    [onOpenChange, openProp]
  )

  const openItem = React.useCallback(
    (id: string) => {
      const ordered = collectItems()
      setIndex(Math.max(0, ordered.findIndex((item) => item.id === id)))
      setOpen(true)
    },
    [collectItems, setIndex, setOpen]
  )

  const context = React.useMemo(
    () => ({ register, openItem }),
    [openItem, register]
  )

  return (
    <ImagePreviewGroupContext.Provider value={context}>
      <DialogPrimitive.Root open={open} onOpenChange={setOpen}>
        <div ref={rootRef} data-slot="image-preview-group" {...props}>
          {children}
        </div>
        <ImagePreviewLightbox
          open={open}
          onOpenChange={setOpen}
          items={items}
          index={Math.min(index, Math.max(items.length - 1, 0))}
          onIndexChange={setIndex}
          minZoom={minZoom}
          maxZoom={maxZoom}
          zoomStep={zoomStep}
          showToolbar={showToolbar}
          previewClassName={previewClassName}
          onCloseAutoFocus={(event) => {
            // There is no single Dialog.Trigger in a group, so return focus to
            // the thumbnail of the image that was viewed last.
            event.preventDefault()
            rootRef.current
              ?.querySelector<HTMLElement>(
                `[data-preview-id="${items[index]?.id}"]`
              )
              ?.focus()
          }}
        />
      </DialogPrimitive.Root>
    </ImagePreviewGroupContext.Provider>
  )
}

type ImagePreviewLightboxProps = LightboxOptions & {
  open: boolean
  onOpenChange: (open: boolean) => void
  items: PreviewItem[]
  index: number
  onIndexChange: (index: number) => void
  onCloseAutoFocus?: (event: Event) => void
}

function ImagePreviewLightbox({
  open,
  onOpenChange,
  items,
  index,
  onIndexChange,
  minZoom,
  maxZoom,
  zoomStep,
  showToolbar,
  previewClassName,
  onCloseAutoFocus,
}: ImagePreviewLightboxProps) {
  const reduceMotion = useReducedMotion()
  const layoutId = React.useId()
  const [zoom, setZoom] = React.useState(1)
  const [rotation, setRotation] = React.useState(0)
  const [direction, setDirection] = React.useState(0)
  // Radix portals mount one tick after `open` flips, so the viewport is kept
  // in state to re-run effects once the element actually exists.
  const [viewport, setViewport] = React.useState<HTMLDivElement | null>(null)
  const stripRef = React.useRef<HTMLDivElement>(null)
  const item = items[index]
  const multiple = items.length > 1
  const fade = reduceMotion ? { duration: 0 } : { duration: 0.22, ease: EASE }

  const resetTransform = React.useCallback(() => {
    setZoom(1)
    setRotation(0)
  }, [])

  React.useEffect(() => {
    if (!open) resetTransform()
  }, [open, resetTransform])

  const changeZoom = React.useCallback(
    (update: (current: number) => number) => {
      setZoom((current) =>
        Math.min(maxZoom, Math.max(minZoom, update(current)))
      )
    },
    [maxZoom, minZoom]
  )

  // React registers wheel listeners as passive, so preventDefault() would be
  // ignored there. A native non-passive listener keeps the page from scrolling
  // while the wheel / trackpad pinch zooms the image.
  React.useEffect(() => {
    if (!viewport) return
    function handleWheel(event: WheelEvent) {
      event.preventDefault()
      if (event.deltaY === 0) return
      const factor = Math.exp(-event.deltaY * (event.ctrlKey ? 0.01 : 0.0015))
      changeZoom((current) => current * factor)
    }
    viewport.addEventListener("wheel", handleWheel, { passive: false })
    return () => viewport.removeEventListener("wheel", handleWheel)
  }, [changeZoom, viewport])

  React.useEffect(() => {
    if (!viewport) return
    stripRef.current
      ?.querySelector<HTMLElement>("[data-active]")
      ?.scrollIntoView({
        block: "nearest",
        inline: "center",
        behavior: reduceMotion ? "auto" : "smooth",
      })
  }, [index, reduceMotion, viewport])

  function goTo(next: number) {
    if (!multiple) return
    const wrapped = (next + items.length) % items.length
    if (wrapped === index) return
    setDirection(next > index ? 1 : -1)
    resetTransform()
    onIndexChange(wrapped)
  }

  function handleDownload() {
    const anchor = document.createElement("a")
    anchor.href = item.src
    anchor.download = item.downloadName ?? ""
    anchor.click()
  }

  function handleKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
    if (event.key === "+" || event.key === "=") changeZoom((z) => z + zoomStep)
    if (event.key === "-") changeZoom((z) => z - zoomStep)
    if (event.key === "0") resetTransform()
    if (event.key === "ArrowLeft") goTo(index - 1)
    if (event.key === "ArrowRight") goTo(index + 1)
  }

  return (
    <AnimatePresence>
      {open && item ? (
        <DialogPrimitive.Portal forceMount>
          <DialogPrimitive.Overlay asChild forceMount>
            <motion.div
              data-slot="image-preview-overlay"
              className="fixed inset-0 z-50 bg-black/90"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={fade}
            />
          </DialogPrimitive.Overlay>

          <DialogPrimitive.Content
            forceMount
            data-slot="image-preview-content"
            className="fixed inset-0 z-50 flex flex-col overflow-hidden text-white outline-none"
            onKeyDown={handleKeyDown}
            onCloseAutoFocus={onCloseAutoFocus}
          >
            <DialogPrimitive.Title className="sr-only">
              图片预览:{item.alt}
            </DialogPrimitive.Title>
            <DialogPrimitive.Description className="sr-only">
              {multiple
                ? `第 ${index + 1} 张,共 ${items.length} 张。使用左右方向键切换图片。`
                : "可缩放、旋转和拖动的大图预览。"}
            </DialogPrimitive.Description>

            <motion.div
              className="relative z-10 flex h-14 shrink-0 items-center justify-between gap-3 px-3"
              initial={reduceMotion ? false : { opacity: 0, y: -8 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0 }}
              transition={fade}
            >
              <span
                aria-hidden="true"
                className="px-2 text-sm tabular-nums text-white/70"
              >
                {multiple ? `${index + 1} / ${items.length}` : null}
              </span>
              <DialogPrimitive.Close asChild>
                <PreviewAction label="关闭预览">
                  <XIcon />
                </PreviewAction>
              </DialogPrimitive.Close>
            </motion.div>

            <div
              ref={setViewport}
              data-slot="image-preview-viewport"
              className="relative min-h-0 flex-1 overflow-hidden"
              onPointerDown={(event) => {
                if (event.target === event.currentTarget) onOpenChange(false)
              }}
            >
              <motion.div
                className="pointer-events-none absolute inset-0"
                initial={reduceMotion ? false : { opacity: 0, scale: 0.94 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={reduceMotion ? undefined : { opacity: 0, scale: 0.96 }}
                transition={reduceMotion ? { duration: 0 } : SPRING}
              >
                <AnimatePresence initial={false} custom={direction}>
                  <motion.div
                    key={item.id}
                    custom={direction}
                    className="absolute inset-0 flex items-center justify-center px-6 py-2 sm:px-16"
                    variants={{
                      enter: (dir: number) => ({ opacity: 0, x: dir * 64 }),
                      center: { opacity: 1, x: 0 },
                      exit: (dir: number) => ({ opacity: 0, x: dir * -64 }),
                    }}
                    initial={reduceMotion ? false : "enter"}
                    animate="center"
                    exit={reduceMotion ? undefined : "exit"}
                    transition={
                      reduceMotion ? { duration: 0 } : { duration: 0.3, ease: EASE }
                    }
                  >
                    <PreviewImage
                      item={item}
                      zoom={zoom}
                      rotation={rotation}
                      swipeable={multiple}
                      className={previewClassName}
                      onSwipe={(step) => goTo(index + step)}
                      onDoubleClick={() =>
                        zoom > 1 ? resetTransform() : changeZoom(() => 2)
                      }
                    />
                  </motion.div>
                </AnimatePresence>
              </motion.div>

              {multiple ? (
                <>
                  <PreviewAction
                    label="上一张"
                    className="absolute left-3 top-1/2 size-10 -translate-y-1/2 rounded-full bg-neutral-900/80 hover:bg-neutral-800"
                    onClick={() => goTo(index - 1)}
                  >
                    <ChevronLeftIcon />
                  </PreviewAction>
                  <PreviewAction
                    label="下一张"
                    className="absolute right-3 top-1/2 size-10 -translate-y-1/2 rounded-full bg-neutral-900/80 hover:bg-neutral-800"
                    onClick={() => goTo(index + 1)}
                  >
                    <ChevronRightIcon />
                  </PreviewAction>
                </>
              ) : null}
            </div>

            <motion.div
              className="relative z-10 flex shrink-0 flex-col items-center gap-3 px-4 pb-4 pt-3"
              initial={reduceMotion ? false : { opacity: 0, y: 8 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0 }}
              transition={fade}
            >
              {item.caption ? (
                <AnimatePresence mode="wait" initial={false}>
                  <motion.p
                    key={item.id}
                    data-slot="image-preview-caption"
                    className="max-w-2xl text-center text-sm text-white/80"
                    initial={reduceMotion ? false : { opacity: 0, y: 4 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={{ opacity: 0, y: -4 }}
                    transition={{ duration: reduceMotion ? 0 : 0.15 }}
                  >
                    {item.caption}
                  </motion.p>
                </AnimatePresence>
              ) : null}

              {showToolbar ? (
                <div
                  data-slot="image-preview-toolbar"
                  className="flex items-center gap-0.5 rounded-md border border-white/10 bg-neutral-900 p-1"
                >
                  <PreviewAction
                    label="缩小"
                    disabled={zoom <= minZoom}
                    onClick={() => changeZoom((z) => z - zoomStep)}
                  >
                    <MinusIcon />
                  </PreviewAction>
                  <span className="min-w-12 px-1 text-center text-xs tabular-nums text-white/70">
                    {Math.round(zoom * 100)}%
                  </span>
                  <PreviewAction
                    label="放大"
                    disabled={zoom >= maxZoom}
                    onClick={() => changeZoom((z) => z + zoomStep)}
                  >
                    <PlusIcon />
                  </PreviewAction>
                  <span className="mx-1 h-5 w-px bg-white/15" />
                  <PreviewAction
                    label="逆时针旋转"
                    onClick={() => setRotation((current) => current - 90)}
                  >
                    <RotateCcwIcon />
                  </PreviewAction>
                  <PreviewAction
                    label="顺时针旋转"
                    onClick={() => setRotation((current) => current + 90)}
                  >
                    <RotateCwIcon />
                  </PreviewAction>
                  <PreviewAction label="重置" onClick={resetTransform}>
                    <span className="text-[11px] font-semibold">1:1</span>
                  </PreviewAction>
                  {item.downloadName ? (
                    <PreviewAction label="下载原图" onClick={handleDownload}>
                      <DownloadIcon />
                    </PreviewAction>
                  ) : null}
                </div>
              ) : null}

              {multiple ? (
                <motion.div
                  ref={stripRef}
                  layoutScroll
                  data-slot="image-preview-thumbnails"
                  className="flex max-w-full gap-2 overflow-x-auto p-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
                >
                  {items.map((thumbnail, thumbnailIndex) => {
                    const active = thumbnailIndex === index
                    return (
                      <button
                        key={thumbnail.id}
                        type="button"
                        data-active={active ? "" : undefined}
                        aria-label={`查看第 ${thumbnailIndex + 1} 张:${thumbnail.alt}`}
                        aria-current={active ? "true" : undefined}
                        className="relative size-12 shrink-0 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-white/60"
                        onClick={() => goTo(thumbnailIndex)}
                      >
                        <img
                          src={thumbnail.src}
                          alt=""
                          className={cn(
                            "size-full rounded-sm object-cover transition-opacity duration-200",
                            active ? "opacity-100" : "opacity-40 hover:opacity-75"
                          )}
                        />
                        {active ? (
                          <motion.span
                            aria-hidden
                            layoutId={`${layoutId}-thumbnail`}
                            className="absolute -inset-1 rounded-md border-2 border-white"
                            transition={reduceMotion ? { duration: 0 } : SPRING}
                          />
                        ) : null}
                      </button>
                    )
                  })}
                </motion.div>
              ) : null}
            </motion.div>
          </DialogPrimitive.Content>
        </DialogPrimitive.Portal>
      ) : null}
    </AnimatePresence>
  )
}

/**
 * One image on the stage. Each image owns its pan position so the outgoing
 * image keeps its offset while the next one slides in.
 */
function PreviewImage({
  item,
  zoom,
  rotation,
  swipeable,
  className,
  onSwipe,
  onDoubleClick,
}: {
  item: PreviewItem
  zoom: number
  rotation: number
  swipeable: boolean
  className?: string
  onSwipe: (step: 1 | -1) => void
  onDoubleClick: () => void
}) {
  const reduceMotion = useReducedMotion()
  const imageRef = React.useRef<HTMLImageElement>(null)
  const x = useMotionValue(0)
  const y = useMotionValue(0)
  const [bounds, setBounds] = React.useState({ x: 0, y: 0 })
  const canPan = bounds.x > 0 || bounds.y > 0

  const measure = React.useCallback(() => {
    const image = imageRef.current
    if (!image) return
    const quarterTurn = Math.abs(Math.round(rotation / 90)) % 2 === 1
    const width = quarterTurn ? image.offsetHeight : image.offsetWidth
    const height = quarterTurn ? image.offsetWidth : image.offsetHeight
    // Keep the image pannable on both axes after zooming. Basing the range on
    // viewport overflow locked one axis for portrait or landscape images.
    const next = {
      x: (width * Math.max(0, zoom - 1)) / 2,
      y: (height * Math.max(0, zoom - 1)) / 2,
    }
    setBounds((current) =>
      current.x === next.x && current.y === next.y ? current : next
    )
    const transition = reduceMotion ? { duration: 0 } : SPRING
    const clampedX = Math.min(next.x, Math.max(-next.x, x.get()))
    const clampedY = Math.min(next.y, Math.max(-next.y, y.get()))
    if (clampedX !== x.get()) animate(x, clampedX, transition)
    if (clampedY !== y.get()) animate(y, clampedY, transition)
  }, [reduceMotion, rotation, x, y, zoom])

  React.useLayoutEffect(() => {
    measure()
    const observer = new ResizeObserver(measure)
    if (imageRef.current) observer.observe(imageRef.current)
    return () => observer.disconnect()
  }, [measure])

  function handleDragEnd(
    _: MouseEvent | TouchEvent | PointerEvent,
    info: PanInfo
  ) {
    const swipe = info.offset.x + info.velocity.x * 0.2
    if (swipe < -80) onSwipe(1)
    else if (swipe > 80) onSwipe(-1)
  }

  return (
    <motion.img
      ref={imageRef}
      data-slot="image-preview-image"
      src={item.src}
      alt={item.alt}
      draggable={false}
      onLoad={measure}
      onDoubleClick={onDoubleClick}
      className={cn(
        "pointer-events-auto max-h-full max-w-full select-none object-contain",
        canPan
          ? "cursor-grab touch-none active:cursor-grabbing"
          : swipeable
            ? "touch-pan-y"
            : "cursor-default",
        className
      )}
      style={{ x, y }}
      drag={canPan ? true : swipeable ? "x" : false}
      dragConstraints={
        canPan
          ? { left: -bounds.x, right: bounds.x, top: -bounds.y, bottom: bounds.y }
          : { left: 0, right: 0 }
      }
      dragElastic={canPan ? 0.08 : 0.35}
      dragMomentum={false}
      onDragEnd={canPan ? undefined : handleDragEnd}
      animate={{ scale: zoom, rotate: rotation }}
      transition={
        reduceMotion
          ? { duration: 0 }
          : { type: "spring", stiffness: 360, damping: 34, mass: 0.7 }
      }
    />
  )
}

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 text-white/85 outline-none transition-colors hover:bg-white/10 hover:text-white focus-visible:ring-2 focus-visible:ring-white/50 disabled:pointer-events-none disabled:opacity-35 [&_svg]:size-4",
        className
      )}
      {...props}
    >
      {children}
    </button>
  )
}

export { ImagePreview, ImagePreviewGroup }

属性 Props

ImagePreview

ImagePreview 支持以下配置属性,并继承原生 <button> 触发器属性。放在 ImagePreviewGroup 内时,open、defaultOpen、onOpenChange 以及缩放与工具栏配置由分组统一管理:

属性类型默认值说明
srcstring—缩略图与默认预览大图的图片地址 URL。
altstring—图片的无障碍替代描述文本(必填)。
previewSrcstring—可选的独立高分辨率原图地址。指定后预览弹窗中将加载该高清图,以节省初始缩略图带宽。
captionReact.ReactNode—大图预览底部展示的图片标题、拍摄参数或版权说明文案。
openboolean—受控模式下大图预览弹窗的打开状态。
defaultOpenbooleanfalse非受控模式下大图预览弹窗的初始打开状态。
onOpenChange(open: boolean) => void—预览弹窗打开或关闭时的状态变更回调函数。
minZoomnumber0.5允许缩小的最小缩放比例限制。
maxZoomnumber3允许放大的最大缩放比例限制。
zoomStepnumber0.25每次点击放大/缩小按钮或触发快捷键时的步进比例增量。
showToolbarbooleantrue是否显示底部变换工具栏(包含缩放、旋转、重置与下载按钮)。
downloadNamestring—下载保存时的默认文件名。传入此项时工具栏将自动出现下载按钮;若省略则隐藏下载按钮。
thumbnailClassNamestring—应用于未展开时外层缩略图 <img> 元素的额外 CSS 类名。
previewClassNamestring—应用于灯箱内部大图 <img> 元素的额外 CSS 类名。

ImagePreviewGroup

将多个 ImagePreview 组合为一个共享灯箱,图片顺序与 DOM 顺序一致。灯箱内提供上一张 / 下一张按钮、底部缩略图条(当前项边框在缩略图间滑动)、左右方向键与横向滑动手势,切换时图片按方向滑入滑出。继承原生 <div> 属性:

属性类型默认值说明
open / defaultOpenboolean- / false共享灯箱的受控 / 初始打开状态。
onOpenChange(open: boolean) => void—灯箱打开或关闭时触发。
index / defaultIndexnumber- / 0灯箱当前展示图片的受控 / 初始索引。
onIndexChange(index: number) => void—灯箱切换到另一张图片时触发。
minZoom / maxZoom / zoomStepnumber0.5 / 3 / 0.25与 ImagePreview 相同的缩放限制,作用于分组内所有图片。
showToolbarbooleantrue是否显示缩放、旋转、重置与下载工具栏。
previewClassNamestring—应用于灯箱大图 <img> 的额外 CSS 类名。

事件 Events

属性类型默认值说明
onOpenChange(open: boolean) => void—灯箱打开或关闭时触发,返回最新的布尔值状态。
onKeyDown(event: React.KeyboardEvent) => void—灯箱内部按键事件。支持使用 +/= 放大、- 缩小、0 重置图片变换、←/→ 在分组中切换图片,或使用 Esc 关闭灯箱。
onClick(event: React.MouseEvent<HTMLButtonElement>) => void—点击缩略图触发器时的原生事件回调。

使用场景与设计规范

ImagePreview 专为电商详情、作品集画廊、附件凭证与设计稿核对设计:

  • 分级加载优化带宽:对于动辄数兆的高清原图,缩略图使用小图 src,灯箱大图传入 previewSrc,实现首屏飞速加载与灯箱高清呈现的双赢。
  • 拖拽平移视口边界约束:当图片放大超过屏幕视口范围时,自动激活鼠标抓手拖拽(Drag to pan)模式,并内置弹性边界吸附,防止图片被拖出屏幕视野之外;缩小后图片会平滑回到可视范围内。
  • 极简 vs 全功能:如果不希望用户旋转或下载图片,设置 showToolbar={false} 即可获得类似 Medium 或 iOS 相册的极简沉浸式阅读预览体验。

场景示例

画廊分组浏览

使用 ImagePreviewGroup 包裹网格中的缩略图,打开后可在同一灯箱内左右切换;每张图片保留各自的说明文字与原图下载:

Loading…

极简纯净模式 (隐藏工具栏)

对于文章配图或列表插图,隐藏工具栏,仅保留点击打开大图、滚轮缩放与点击背景 / 按 Esc 关闭的能力:

Loading…

无障碍与交互 Accessibility

  • Radix Dialog 焦点管理:打开灯箱时焦点自动锁定在弹窗内,关闭后焦点恢复至最后查看图片的缩略图,防止键盘焦点丢失。
  • 快捷键导航:
    • + / =:放大图片比例。
    • -:缩小图片比例。
    • 0:重置为原始 1:1 比例与 0 度旋转。
    • ← / →:在 ImagePreviewGroup 中切换上一张 / 下一张。
    • Esc:立即退出预览。
  • 减少动态效果:系统开启“减少动态效果”时,打开、切换与缩放均改为瞬时完成。
  • 屏幕阅读器:内置 DialogPrimitive.Title(带 sr-only 隐藏但读屏可达)自动播报图片 alt 描述信息。