Image

An image that loads lazily, holds its box and falls back when it fails.

A slope on a summer afternoon

ATRI

Sci-fi
<script setup lang="ts">
  import { Card, Image, Stack, Tag, Text } from '@hina-ui/vue'
</script>

<template>
  <Card class="w-full max-w-xs" :padded="false">
    <Image
      src="/sample.webp"
      alt="A slope on a summer afternoon"
      :ratio="4 / 3"
      :lazy="false"
      eager
      class="rounded-t-lg"
    />
    <Stack gap="xs" align="start" class="p-4">
      <Text class="font-medium">ATRI</Text>
      <Tag>Sci-fi</Tag>
    </Stack>
  </Card>
</template>
vue

Usage

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

The picture waits until it comes near the viewport, with a skeleton holding the box while it loads; once the picture is ready the skeleton fades away. Give the box a size through classes, or a ratio to have it reserve the height in advance.

A slope on a summer afternoon
<script setup lang="ts">
  import { Image } from '@hina-ui/vue'
</script>

<template>
  <Image
    src="/sample.webp"
    alt="A slope on a summer afternoon"
    class="h-40 w-full max-w-md rounded-md"
  />
</template>
vue

Examples

Aspect ratio

ratio is the width divided by the height. The box takes that shape before the picture exists, so nothing on the page moves when it lands.

1 / 1

A slope on a summer afternoon

4 / 3

A slope on a summer afternoon

16 / 9

A slope on a summer afternoon
<script setup lang="ts">
  import { Image, Inline, Stack, Text } from '@hina-ui/vue'

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

<template>
  <Inline align="start" class="gap-4">
    <Stack v-for="ratio in ratios" :key="ratio.label" gap="xs" class="w-40">
      <Text tone="muted" size="sm">{{ ratio.label }}</Text>
      <Image
        src="/sample.webp"
        alt="A slope on a summer afternoon"
        :ratio="ratio.value"
        class="rounded-md"
      />
    </Stack>
  </Inline>
</template>
vue

Container and image styles

class and style apply to the outer box; imageClass and imageStyle apply to the inner img. style accepts strings, objects and arrays. Its aspectRatio overrides ratio.

<script setup lang="ts">
  import { Image } from '@hina-ui/vue'
</script>

<template>
  <Image
    src="/sample.webp"
    alt="A hillside on a summer afternoon"
    preview
    class="rounded-md"
    :style="{ width: '240px', height: '160px' }"
    :image-style="{ objectPosition: 'left center' }"
  />
</template>
vue

Fit

fit decides how the picture fills its box; the default is cover.

cover

A slope on a summer afternoon

contain

A slope on a summer afternoon

fill

A slope on a summer afternoon
<script setup lang="ts">
  import { Image, Inline, Stack, Text } from '@hina-ui/vue'

  const fits = ['cover', 'contain', 'fill'] as const
</script>

<template>
  <Inline align="start" class="gap-4">
    <Stack v-for="fit in fits" :key="fit" gap="xs" class="w-32">
      <Text tone="muted" size="sm">{{ fit }}</Text>
      <Image
        src="/sample.webp"
        alt="A slope on a summer afternoon"
        :fit="fit"
        :ratio="1"
        class="bg-inset rounded-md"
      />
    </Stack>
  </Inline>
</template>
vue

First-screen pictures

lazy is on by default, so a picture waits for the observer and is rendered without an address on the server. Turn lazy off and eager on for the pictures above the fold: the address ships with the first HTML, the request goes out at fetchpriority="high", and decoding switches to synchronous.

A slope on a summer afternoon
<script setup lang="ts">
  import { Image } from '@hina-ui/vue'
</script>

<template>
  <Image
    src="/sample.webp"
    alt="A slope on a summer afternoon"
    :lazy="false"
    eager
    :ratio="16 / 9"
    class="w-full max-w-md rounded-md"
  />
</template>
vue

Blurred placeholder

thumbhash takes the picture’s ThumbHash as a base64 string, in either the standard or the URL-safe alphabet. While the picture loads, the box shows the blurred thumbnail decoded from it instead of the skeleton; once decoded, the picture and the thumbnail crossfade, the picture fading in while the thumbnail fades out, starting and ending together.

A ThumbHash comes from rgbaToThumbHash in the thumbhash package, given the pixels of the picture scaled down to 100 pixels or less. It is usually generated at upload time and stored with the picture’s address.

A slope on a summer afternoon
<script setup lang="ts">
  import { shallowRef } from 'vue'
  import { Button, Image, Stack } from '@hina-ui/vue'

  const round = shallowRef(0)
</script>

<template>
  <Stack align="start" class="w-full max-w-md">
    <Image
      :key="round"
      :src="`/sample.webp?round=${round}`"
      thumbhash="YMcJJIIhukWZenmwmHWb9Pc5nw=="
      alt="A slope on a summer afternoon"
      :ratio="16 / 9"
      class="rounded-md"
    />
    <Button size="sm" variant="soft" tone="neutral" @click="round++">Reload</Button>
  </Stack>
</template>
vue

Empty and failed

Without src the component renders the empty slot. When a picture fails and there is no fallback left, it renders the error slot instead.

Loaded

A slope on a summer afternoon

No source

Failed

<script setup lang="ts">
  import { ImageOff } from '@lucide/vue'
  import { Center, Image, Inline, Stack, Text } from '@hina-ui/vue'
</script>

<template>
  <Inline align="start" class="gap-4">
    <Stack gap="xs" class="w-40">
      <Text tone="muted" size="sm">Loaded</Text>
      <Image src="/sample.webp" alt="A slope on a summer afternoon" :ratio="1" class="rounded-md" />
    </Stack>
    <Stack gap="xs" class="w-40">
      <Text tone="muted" size="sm">No source</Text>
      <Image :ratio="1" class="bg-inset rounded-md">
        <template #empty>
          <Center class="text-muted size-full">
            <ImageOff class="size-6" />
          </Center>
        </template>
      </Image>
    </Stack>
    <Stack gap="xs" class="w-40">
      <Text tone="muted" size="sm">Failed</Text>
      <Image src="/missing.webp" alt="" :ratio="1" class="bg-inset rounded-md">
        <template #error>
          <Center class="text-muted size-full">
            <Text size="sm">Unavailable</Text>
          </Center>
        </template>
      </Image>
    </Stack>
  </Inline>
</template>
vue

Falling back

fallback is loaded when src fails. Should the fallback fail as well, the error slot takes over and error is emitted.

A slope on a summer afternoon
<script setup lang="ts">
  import { Image } from '@hina-ui/vue'
</script>

<template>
  <Image
    src="/missing.webp"
    fallback="/sample.webp"
    alt="A slope on a summer afternoon"
    :ratio="16 / 9"
    class="w-full max-w-md rounded-md"
  />
</template>
vue

Resolving addresses

The component only takes src, and does not care whether it is a full address or a key in object storage. provideImageResolver injects a function that turns src into the final address; anything like processing parameters belongs to that function and never has to travel through the component. Without a resolver, src is used as it is. The function also receives a second argument naming the purpose: 'image' for the picture on the page, 'preview' for the large picture shown in the preview.

A slope on a summer afternoon

The resolver above appends a query string; a real application would point the address at a CDN and an image processor instead.

<script setup lang="ts">
  import { Image, Stack, Text, provideImageResolver } from '@hina-ui/vue'

  provideImageResolver(src => `${src}?preset=banner&quality=82`)
</script>

<template>
  <Stack class="w-full max-w-md">
    <Image
      src="/sample.webp"
      alt="A slope on a summer afternoon"
      :ratio="16 / 9"
      class="rounded-md"
    />
    <Text tone="muted" size="sm">
      The resolver above appends a query string; a real application would point the address at a CDN
      and an image processor instead.
    </Text>
  </Stack>
</template>
vue

Preview

With preview set, the picture can be clicked to view it enlarged: it grows from its place on the page to the centre of the screen and shrinks back on close. The preview supports zoom, drag, rotation and download, and dragging the picture down also closes it.

The small and large pictures can be two renditions: the resolver addresses each by purpose, or the large address is passed to preview directly. The preview opens with the small picture from the page and swaps in the large one once it is ready.

<script setup lang="ts">
  import { Image, provideImageResolver } from '@hina-ui/vue'

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

<template>
  <Image
    src="images/1ec4bca1-0674-4da6-b1a2-7433f1d79df5.webp"
    alt="The cover of Machikado Mazoku volume 1"
    preview
    class="size-48 rounded-md"
  />
</template>
vue

Use Lightbox to control a preview independently.

Preview dimensions

Use previewSize to provide the final preview image’s intrinsic pixel dimensions. When preview resolves to a separate image, the dimensions describe that larger image; otherwise they describe the current image. The initial fit and zoom limit are known before opening, so the preview expands directly to its final size and loading the larger image only replaces its pixels.

Both dimensions must be finite positive numbers. Valid explicit dimensions take precedence over decoded dimensions. Omitted or invalid values keep automatic sizing. previewSize only affects the preview; class, style and ratio still control the picture’s outer box on the page.

The example compares automatic and supplied dimensions using the same 320 × 180 thumbnail and 1200 × 675 larger image.

Detect dimensions

Known dimensions

<script setup lang="ts">
  import { Image, Inline, Stack, Text } from '@hina-ui/vue'

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

<template>
  <Inline align="start" class="gap-6">
    <Stack gap="sm" class="w-64">
      <Text size="sm" tone="muted">Detect dimensions</Text>
      <Image
        :src="image.thumbnail"
        :preview="image.original"
        alt="A hillside on a summer afternoon"
        :ratio="16 / 9"
        class="rounded-xl"
      />
    </Stack>
    <Stack gap="sm" class="w-64">
      <Text size="sm" tone="muted">Known dimensions</Text>
      <Image
        :src="image.thumbnail"
        :preview="image.original"
        :preview-size="{ width: image.width, height: image.height }"
        alt="A hillside on a summer afternoon"
        :ratio="16 / 9"
        class="rounded-xl"
      />
    </Stack>
  </Inline>
</template>
vue

Groups

Put several pictures inside an ImageGroup and opening any of them lets you move through the whole set, in the order they appear on the page. With loop on, paging wraps around at both ends.

<script setup lang="ts">
  import { Image, ImageGroup, Inline, provideImageResolver } from '@hina-ui/vue'

  provideImageResolver(
    (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: 'A screenshot of Shukusei no Girlfriend 3',
      class: 'w-48',
    },
    { src: 'galgame/10003/orhsttk4_2.jpg', alt: 'The cover of A Sky Full of Stars', class: 'w-20' },
    { src: 'galgame/10012/co4a4kyg_2.png', alt: 'The key visual of ONE.', class: 'w-20' },
    {
      src: 'images/1ec4bca1-0674-4da6-b1a2-7433f1d79df5.webp',
      alt: 'The cover of Machikado Mazoku volume 1',
      class: 'w-20',
    },
    {
      src: 'images/74b0eddb-819f-4ee8-b406-fa096943a5b9.webp',
      alt: 'The cover of A Sky Full of Stars FINE DAYS',
      class: 'w-40',
    },
  ]
</script>

<template>
  <ImageGroup loop>
    <Inline>
      <Image
        v-for="picture in pictures"
        :key="picture.src"
        :src="picture.src"
        :alt="picture.alt"
        preview
        :class="`h-28 rounded-md ${picture.class}`"
      />
    </Inline>
  </ImageGroup>
</template>
vue

Behaviour

  • Preview transitions preserve rounded corners from the image and its clipping ancestors. Each picture uses the corners that meet its clipping boundaries.
  • The outer box fills its container width by default, with or without preview. An explicit width class overrides this default.
  • Lazy by default: an intersection observer watches the box, and the address is only attached once the picture comes within rootMargin of the viewport, which defaults to 200 pixels.
  • On the server the observer has not run yet, so a lazy picture is rendered without an address. Turn lazy off and eager on for first-screen pictures.
  • The skeleton covers the whole box, including the space left empty by contain. Once decoded, the picture fades in above it over 300ms while the skeleton fades out over 200ms, both with ease-out easing.
  • A lazy picture stays transparent until its fade begins. Disabling the skeleton keeps the picture’s fade-in.
  • A picture with lazy off sits above the skeleton and appears as soon as the browser has painted it, without waiting for script; the skeleton beneath it is dropped outright, with no fade.
  • With thumbhash set, the decoded thumbnail shows while loading instead of a skeleton, and neither skeleton nor a custom skeleton has any effect. A value that cannot be decoded counts as unset.
  • The thumbnail is laid out like the picture: it reads the picture’s computed object-fit, object-position, padding and border, so changing them through imageClass or imageStyle moves the thumbnail as well. cover and none fill, contain and scale-down show it whole, and fill stretches it to the box. The server-rendered HTML lays it out by fit; once script runs, it follows the picture.
  • A ThumbHash only records an approximate aspect ratio. With contain and scale-down, the thumbnail scales to the picture’s actual bounds during the crossfade, and the two coincide when it ends.
  • The thumbnail is part of the server-rendered HTML and shows before any script loads.
  • Once decoded, a lazy picture crossfades with the thumbnail over 450ms. Both follow the same ease-in-out curve and their opacities add up to 1 at every moment: nothing behind the box shows through, and the thumbnail showing around a transparent picture fades away as the picture fades in.
  • The crossfade blends only the picture and the thumbnail; the box’s own background takes no part in it.
  • A picture with lazy off covers the thumbnail directly, and the thumbnail fades out over 450ms once the picture has loaded.
  • When a picture already on screen gets a new src, the old picture fades out over 450ms over the new picture’s thumbnail.
  • When loading fails, the thumbnail goes with the picture.
  • Where the browser has no intersection observer, the picture loads as soon as the component mounts rather than being held back forever.
  • Attributes that are not props land on the img element, so sizes, srcset and the like work as usual.
  • Changing src resets the fallback, so a new picture starts from its own address rather than the previous fallback.
  • While the preview is open the page cannot scroll and focus stays inside the preview; closing returns focus to the picture.
  • Dragging down moves the picture with the finger and shrinks it while the page shows through the background; letting go before it has travelled far enough springs it back.
  • Pinching zooms around the midpoint of the fingers and the wheel zooms around the pointer. Small images are not enlarged on open; double-tap targets depend on image dimensions, and manual zoom reaches twice the intrinsic size. See Lightbox sizing and zoom. Pinching beyond either limit meets increasing resistance and springs back on release.
  • The picture can only be dragged around once zoomed in. Dragging past an edge meets resistance and springs back; a quick flick keeps the picture gliding to a stop, bouncing off the edges. When zoomed, dragging down only pans and never closes.
  • Turning the page resets the previous picture’s zoom, position and rotation; when pictures are added to or removed from the page, the group in the preview follows. With a single picture, or once you reach either end, dragging meets resistance and springs back.
  • Only the current picture fetches its large rendition; moving on abandons it, so the whole set is never pulled down at once.
  • Closing and reopening starts over, with zoom and position reset.
  • When the system asks for reduced motion, opening and closing no longer travel, and zooming and gliding lose their transitions as well.

Accessibility

  • alt goes straight to the img. Leave it empty for decorative pictures so screen readers skip them.
  • The skeleton is aria-hidden; the loading state belongs to the region that owns the picture.
  • A picture with preview is a button, named by its alt, which also titles the preview; an empty alt triggers a warning in development.
  • While the preview is open, ← → switch pictures. Every button on the thumbnail strip is named by its picture’s alt, and the current one carries aria-current.

API

Prop
Type
Default
Description
src
string
—
The address, passed through the resolver
alt
string
''
Alternative text
fallback
string
—
Loaded when src fails
thumbhash
string
—
The picture’s ThumbHash, shown as a blurred thumbnail while loading
fit
'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
'cover'
How the picture fills its box
ratio
number
—
Width divided by height, reserved in advance
lazy
boolean
true
Wait until it nears the viewport
rootMargin
string
'200px'
How early loading starts
skeleton
boolean
true
Whether a skeleton shows while loading
eager
boolean
false
Request at high priority, decode in sync
preview
boolean | string
false
Opens for a closer look; an address becomes the large source
previewSize
{ width: number; height: number }
—
Intrinsic dimensions of the final preview image; both must be finite and positive
draggable
boolean
—
Whether the picture can be dragged
class
string
—
Classes appended to the box
style
StyleValue
—
Inline styles for the outer box
imageClass
string
—
Classes appended to the img
imageStyle
StyleValue
—
Inline styles for the img
Event
Payload
Description
load
size: { width, height }
The picture has loaded
error
—
Every address has been tried
Slot
Description
skeleton
Replaces the built-in loading skeleton
empty
Rendered when there is no src
error
Rendered when loading has failed