Image 图片

懒加载、撑住位置并在失败时回退的图片。

夏日午后的坡道

ATRI

科幻
import { Card, Image, Stack, Tag, Text } from '@hina-ui/react'

export default function Demo() {
  return (
    <Card className="w-full max-w-xs" padded={false}>
      <Image
        src="/sample.webp"
        alt="夏日午后的坡道"
        ratio={4 / 3}
        lazy={false}
        eager
        className="rounded-t-lg"
      />
      <Stack gap="xs" align="start" className="p-4">
        <Text className="font-medium">ATRI</Text>
        <Tag>科幻</Tag>
      </Stack>
    </Card>
  )
}
tsx

用法

import { Image } from '@hina-ui/react'
ts

图片在接近视口时才开始加载,加载期间由骨架占位,图片就绪后骨架淡出。用类名给出尺寸,或者用 ratio 提前占好高度。

夏日午后的坡道
import { Image } from '@hina-ui/react'

export default function Demo() {
  return (
    <Image src="/sample.webp" alt="夏日午后的坡道" className="h-40 w-full max-w-md rounded-md" />
  )
}
tsx

示例

宽高比

ratio 是宽除以高。图片还不存在时框就已经是这个形状,因此图片到位不会顶动页面。

1 / 1

夏日午后的坡道

4 / 3

夏日午后的坡道

16 / 9

夏日午后的坡道
import { Image, Inline, Stack, Text } from '@hina-ui/react'

const ratios = [
  { label: '1 / 1', value: 1 },
  { label: '4 / 3', value: 4 / 3 },
  { label: '16 / 9', value: 16 / 9 },
]

export default function Demo() {
  return (
    <Inline align="start" className="gap-4">
      {ratios.map(ratio => (
        <Stack key={ratio.label} gap="xs" className="w-40">
          <Text tone="muted" size="sm">
            {ratio.label}
          </Text>
          <Image
            src="/sample.webp"
            alt="夏日午后的坡道"
            ratio={ratio.value}
            className="rounded-md"
          />
        </Stack>
      ))}
    </Inline>
  )
}
tsx

外框与图片样式

className、style 设置外框,imageClass、imageStyle 设置内部的 img。style 中的 aspectRatio 会覆盖 ratio。

import { Image } from '@hina-ui/react'

export default function Demo() {
  return (
    <Image
      src="/sample.webp"
      alt="夏日午后的坡道"
      preview
      className="rounded-md"
      style={{ width: '240px', height: '160px' }}
      imageStyle={{ objectPosition: 'left center' }}
    />
  )
}
tsx

填充方式

fit 决定图片如何填满外框,默认为 cover。

cover

夏日午后的坡道

contain

夏日午后的坡道

fill

夏日午后的坡道
import { Image, Inline, Stack, Text } from '@hina-ui/react'

const fits = ['cover', 'contain', 'fill'] as const

export default function Demo() {
  return (
    <Inline align="start" className="gap-4">
      {fits.map(fit => (
        <Stack key={fit} gap="xs" className="w-32">
          <Text tone="muted" size="sm">
            {fit}
          </Text>
          <Image
            src="/sample.webp"
            alt="夏日午后的坡道"
            fit={fit}
            ratio={1}
            className="bg-inset rounded-md"
          />
        </Stack>
      ))}
    </Inline>
  )
}
tsx

首屏图片

lazy 默认开启,图片要等观察器放行才开始请求,服务端渲染阶段也不会带上地址。首屏图片应当关闭 lazy 并开启 eager:地址随首屏 HTML 一同送达,请求以 fetchpriority="high" 发出,解码方式也改为同步。

夏日午后的坡道
import { Image } from '@hina-ui/react'

export default function Demo() {
  return (
    <Image
      src="/sample.webp"
      alt="夏日午后的坡道"
      lazy={false}
      eager
      ratio={16 / 9}
      className="w-full max-w-md rounded-md"
    />
  )
}
tsx

空态与失败

没有 src 时渲染 empty 属性。图片加载失败且没有可用的回退地址时,渲染 error 属性。

加载完成

夏日午后的坡道

没有图片

加载失败

import { ImageOff } from 'lucide-react'
import { Center, Image, Inline, Stack, Text } from '@hina-ui/react'

export default function Demo() {
  return (
    <Inline align="start" className="gap-4">
      <Stack gap="xs" className="w-40">
        <Text tone="muted" size="sm">
          加载完成
        </Text>
        <Image src="/sample.webp" alt="夏日午后的坡道" ratio={1} className="rounded-md" />
      </Stack>
      <Stack gap="xs" className="w-40">
        <Text tone="muted" size="sm">
          没有图片
        </Text>
        <Image
          ratio={1}
          className="bg-inset rounded-md"
          empty={
            <Center className="text-muted size-full">
              <ImageOff className="size-6" />
            </Center>
          }
        />
      </Stack>
      <Stack gap="xs" className="w-40">
        <Text tone="muted" size="sm">
          加载失败
        </Text>
        <Image
          src="/missing.webp"
          alt=""
          ratio={1}
          className="bg-inset rounded-md"
          error={
            <Center className="text-muted size-full">
              <Text size="sm">图片不可用</Text>
            </Center>
          }
        />
      </Stack>
    </Inline>
  )
}
tsx

回退

src 加载失败后改用 fallback。回退地址同样失败时交给 error 属性,并调用 onError。

夏日午后的坡道
import { Image } from '@hina-ui/react'

export default function Demo() {
  return (
    <Image
      src="/missing.webp"
      fallback="/sample.webp"
      alt="夏日午后的坡道"
      ratio={16 / 9}
      className="w-full max-w-md rounded-md"
    />
  )
}
tsx

解析地址

组件只接收 src,不关心它是完整地址还是对象存储中的键。用 ImageResolverProvider 包住应用或页面,通过 resolver 提供一个函数,由它把 src 变成最终地址;处理参数之类的细节由这个函数自行掌握,不必经过组件。没有提供解析器时,src 按原样使用。函数还会收到第二个参数说明用途:页面上的图片为 'image',预览时放大查看的大图为 'preview'。

夏日午后的坡道

这里的解析器为地址补充查询串,真实应用会将地址指向 CDN 与图片处理服务。

'use client'

import { Image, ImageResolverProvider, Stack, Text, type ImageResolver } from '@hina-ui/react'

const resolver: ImageResolver = src => `${src}?preset=banner&quality=82`

export default function Demo() {
  return (
    <ImageResolverProvider resolver={resolver}>
      <Stack className="w-full max-w-md">
        <Image src="/sample.webp" alt="夏日午后的坡道" ratio={16 / 9} className="rounded-md" />
        <Text tone="muted" size="sm">
          这里的解析器为地址补充查询串,真实应用会将地址指向 CDN 与图片处理服务。
        </Text>
      </Stack>
    </ImageResolverProvider>
  )
}
tsx

预览

设置 preview 后,图片可以点击放大查看:图片从页面上的原位放大至屏幕中央,关闭时缩回原位。预览支持缩放、拖动、旋转与下载,向下拖动图片也可以关闭。

小图和大图可以是两种规格:解析器按用途给出各自的地址,或者直接把大图地址传给 preview。打开时先显示页面上的小图,大图就绪后替换。

'use client'

import { Image, ImageResolverProvider, type ImageResolver } from '@hina-ui/react'

const resolver: ImageResolver = (src, variant) =>
  `https://imagesp.yurari.moe/${src}?w=${variant === 'preview' ? 2000 : 600}&f=webp&fit=scale-down&q=85`

export default function Demo() {
  return (
    <ImageResolverProvider resolver={resolver}>
      <Image
        src="images/1ec4bca1-0674-4da6-b1a2-7433f1d79df5.webp"
        alt="《街角魔族》第一卷的封面"
        preview
        className="size-48 rounded-md"
      />
    </ImageResolverProvider>
  )
}
tsx

需要独立控制预览时,可以直接使用 Lightbox。

预览原始尺寸

通过 previewSize 提供最终预览图片的原始像素宽高。存在独立的 preview 地址时,尺寸对应解析后的大图;否则对应当前图片。组件据此提前计算初始适配尺寸和缩放上限,打开时一次展开到位,大图加载完成后只替换画质。

宽高必须同时是有限正数。有效的显式尺寸始终优先于加载结果;未传入或无效时,继续自动获取尺寸。previewSize 只影响预览,页面外框仍由 className、style 和 ratio 控制。

下例对比自动获取尺寸与预先提供尺寸,两张图片使用同一份 320 × 180 缩略图和 1200 × 675 大图。

自动获取尺寸

预先提供尺寸

import { Image, Inline, Stack, Text } from '@hina-ui/react'

const image = {
  thumbnail: '/sample-thumbnail.webp',
  original: '/sample.webp',
  width: 1200,
  height: 675,
}

export default function Demo() {
  return (
    <Inline align="start" className="gap-6">
      <Stack gap="sm" className="w-64">
        <Text size="sm" tone="muted">
          自动获取尺寸
        </Text>
        <Image
          src={image.thumbnail}
          preview={image.original}
          alt="夏日午后的坡道"
          ratio={16 / 9}
          className="rounded-xl"
        />
      </Stack>
      <Stack gap="sm" className="w-64">
        <Text size="sm" tone="muted">
          预先提供尺寸
        </Text>
        <Image
          src={image.thumbnail}
          preview={image.original}
          previewSize={{ width: image.width, height: image.height }}
          alt="夏日午后的坡道"
          ratio={16 / 9}
          className="rounded-xl"
        />
      </Stack>
    </Inline>
  )
}
tsx

分组

把多张图片放进 ImageGroup,点击任意一张后可以在整组之间切换,顺序与页面上的顺序一致。开启 loop 后翻页首尾相接。

'use client'

import {
  Image,
  ImageGroup,
  ImageResolverProvider,
  Inline,
  type ImageResolver,
} from '@hina-ui/react'

const resolver: ImageResolver = (src, variant) =>
  `https://imagesp.yurari.moe/${src}?w=${variant === 'preview' ? 2000 : 600}&f=webp&fit=scale-down&q=85`

const pictures = [
  { src: 'galgame/10509/y71oty0w_2.jpg', alt: '《宿星的女友 3》的截图', className: 'w-48' },
  {
    src: 'galgame/10003/orhsttk4_2.jpg',
    alt: '《抬头看看吧,看那天上的繁星》的封面',
    className: 'w-20',
  },
  { src: 'galgame/10012/co4a4kyg_2.png', alt: '《ONE.》的主视觉', className: 'w-20' },
  {
    src: 'images/1ec4bca1-0674-4da6-b1a2-7433f1d79df5.webp',
    alt: '《街角魔族》第一卷的封面',
    className: 'w-20',
  },
  {
    src: 'images/74b0eddb-819f-4ee8-b406-fa096943a5b9.webp',
    alt: '《抬头看看吧,看那天上的繁星 FINE DAYS》的封面',
    className: 'w-40',
  },
]

export default function Demo() {
  return (
    <ImageResolverProvider resolver={resolver}>
      <ImageGroup loop>
        <Inline>
          {pictures.map(picture => (
            <Image
              key={picture.src}
              src={picture.src}
              alt={picture.alt}
              preview
              className={`h-28 rounded-md ${picture.className}`}
            />
          ))}
        </Inline>
      </ImageGroup>
    </ImageResolverProvider>
  )
}
tsx

行为

  • 预览的开关动画保留图片及外层裁剪容器的圆角;多图布局按每张图片实际接触的裁剪边界分别计算四角。
  • 外框默认撑满容器宽度,开启或关闭 preview 时一致;显式宽度类名可以覆盖默认值。
  • 默认懒加载:交叉观察器观察外框,图片距视口不足 rootMargin 时才带上地址开始请求,该值默认为 200 像素。
  • 服务端渲染阶段观察器尚未介入,懒加载的图片先不带地址。首屏图片应当关闭 lazy 并开启 eager。
  • 骨架铺满外框,fit 为 contain 时留出的空白同样被覆盖。图片解码完成后,在骨架上层以 300ms 淡入,骨架同时以 200ms 淡出,两者均使用 ease-out 曲线。
  • 懒加载的图片在淡入开始前保持透明;关闭骨架后,图片仍然淡入。
  • 关闭 lazy 的图片位于骨架上层,浏览器完成绘制即可见,无需等待脚本;其下方的骨架直接移除,不做淡出。
  • 浏览器不支持交叉观察器时,组件挂载后立即加载,不会使图片始终无法显示。
  • 组件未声明的其余特性会落到 img 上,因此 sizes、srcset 之类照常可用。
  • 更换 src 会重置回退状态,新图片从自己的地址开始加载,而不是沿用上一张的回退地址。
  • 预览打开期间页面不能滚动,焦点留在预览层内;关闭后焦点回到图片。
  • 向下拖动时图片随指针下移并缩小,背景逐渐透出页面;松开时拖动距离不足则弹回原处。
  • 双指捏合以两指中点为中心缩放,滚轮以指针所指的点为中心缩放。初次打开不放大小图,双击目标由图片尺寸决定,手动放大上限为原图的 2 倍;具体规则见 Lightbox 的尺寸与缩放。捏合超出上下限时阻力逐渐增大,松开后弹回限值。
  • 放大之后才能拖动查看,拖到边缘有阻力,松开后弹回;快速滑动后画面继续滑行并逐渐停下,到达边界时回弹。放大状态下向下拖动只平移画面,不会关闭。
  • 切换图片时上一张的缩放、位置与旋转复位;组内图片增删后,预览中的图片随之更新。只有一张图片,或者未开启 loop 且已经到达两端时,继续拖动有阻力并回弹。
  • 只为当前图片请求大图,切走后放弃请求,不会一次请求整组的大图。
  • 关闭后再次打开时,缩放与位置都恢复初始状态。
  • 系统开启减弱动态效果后,预览的打开、关闭、缩放、翻页与滑行都不再有过渡,直接切换到结果。

无障碍

  • alt 直接落到 img 上。装饰性图片留空,屏幕阅读器会跳过它。
  • 骨架带 aria-hidden,加载状态由拥有这张图片的区域负责播报。
  • 设置 preview 的图片渲染为按钮,alt 是它的可访问名称,也是预览层的标题;alt 为空时开发环境会输出警告。
  • 预览打开期间可用 ← → 键切换图片。缩略图栏中的每个按钮以图片的 alt 命名,当前项带 aria-current。

API

属性
类型
默认值
说明
src
string
—
图片地址,会经过解析器
alt
string
''
替代文本
fallback
string
—
src 失败后改用的地址
fit
'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
'cover'
图片如何填满外框
ratio
number
—
宽除以高,提前占位
lazy
boolean
true
是否等接近视口再加载
rootMargin
string
'200px'
提前多少距离开始加载
skeleton
boolean
true
加载期间是否显示骨架
eager
boolean
false
高优先级请求并同步解码
preview
boolean | string
false
是否可以点击放大查看,传入字符串时作为大图地址
previewSize
{ width: number; height: number }
—
最终预览图片的原始像素尺寸,宽高须为有限正数
draggable
boolean
—
图片是否可拖拽
className
string
—
追加至外框的类名
style
CSSProperties
—
外框的内联样式
imageClass
string
—
追加至 img 的类名
imageStyle
CSSProperties
—
img 的内联样式
回调
参数
说明
onLoad
size: { width, height }
图片加载完成
onError
—
所有地址都已尝试过
属性
说明
skeletonContent
替换内置的加载骨架
empty
没有 src 时渲染
error
加载失败时渲染