
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>
Usage
import { Image } from '@hina-ui/vue'
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.
<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>
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
4 / 3
16 / 9
<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>
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>
Fit
fit decides how the picture fills its box; the default is cover.
cover
contain
fill
<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>
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.

<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>
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.
<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>
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
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>
Falling back
fallback is loaded when src fails. Should the fallback fail as well, the error slot takes over and error is emitted.
<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>
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.
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>
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>
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>
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>
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
rootMarginof 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
lazyoff andeageron 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
lazyoff 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
thumbhashset, the decoded thumbnail shows while loading instead of a skeleton, and neitherskeletonnor 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 throughimageClassorimageStylemoves the thumbnail as well.coverandnonefill,containandscale-downshow it whole, andfillstretches it to the box. The server-rendered HTML lays it out byfit; once script runs, it follows the picture. - A ThumbHash only records an approximate aspect ratio. With
containandscale-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
lazyoff 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
imgelement, sosizes,srcsetand the like work as usual. - Changing
srcresets 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
altgoes straight to theimg. 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
previewis a button, named by itsalt, which also titles the preview; an emptyalttriggers a warning in development. - While the preview is open,
←→switch pictures. Every button on the thumbnail strip is named by its picture’salt, and the current one carriesaria-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 |