Lightbox

A full-screen image preview with controlled visibility and selection.

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Lightbox, type LightboxItem } from '@hina-ui/vue'

  const open = ref(false)
  const items: LightboxItem[] = [
    { id: 'image-1', src: '/sample.webp', alt: 'A hillside on a summer afternoon' },
  ]
</script>

<template>
  <Button variant="outline" tone="neutral" @click="open = true">Open preview</Button>
  <Lightbox v-model:open="open" :items="items" />
</template>
vue

Usage

import { Lightbox, type LightboxItem } from '@hina-ui/vue'
ts

Provide images through items, control visibility with v-model:open, and select the current image with v-model:index. Mount the component in the Vue component tree; event handlers can update these values.

Lightbox works independently of Image. The Button in the example only opens the preview.

Source transition

Return the source <img> element from the item’s source callback. The preview expands from that image and returns to its current position when closed. Set fit to match the source image’s cropping; rounded clipping on an outer container also participates in the transition.

The example wraps Image in a Button and reads the source image from the click event. The callback can also return an existing native <img> on the page.

<script setup lang="ts">
  import { ref, shallowRef } from 'vue'
  import { Button, Image, Lightbox, type LightboxItem } from '@hina-ui/vue'

  const open = ref(false)
  const source = shallowRef<HTMLImageElement | null>(null)
  const items: LightboxItem[] = [
    {
      id: 'image-1',
      src: '/sample.webp',
      alt: 'A hillside on a summer afternoon',
      fit: 'cover',
      source: () => source.value,
    },
  ]

  function show(event: MouseEvent) {
    source.value = (event.currentTarget as HTMLElement).querySelector('img')
    open.value = true
  }
</script>

<template>
  <Button
    variant="ghost"
    tone="neutral"
    :ripple="false"
    aria-label="Open image preview"
    class="h-auto w-64 cursor-zoom-in overflow-hidden rounded-xl border-0 p-0"
    @click="show"
  >
    <Image
      src="/sample.webp"
      alt="A hillside on a summer afternoon"
      :ratio="16 / 9"
      :lazy="false"
      class="w-64"
    />
  </Button>
  <Lightbox v-model:open="open" :items="items" />
</template>
vue

Rectangle source

source can also return { x, y, width, height } in viewport coordinates, measured in CSS pixels. No <img> element is required. Pass existing bounds directly with source: () => bounds.

A valid previewSize lets the preview expand from the rectangle immediately. Otherwise, the first opening decodes src to obtain its intrinsic dimensions. Closing calls source again to read the latest bounds. Return null or undefined when the source is no longer visible to fade out instead. Rectangles with zero size, non-finite values or no intersection with the viewport are ignored.

The example uses a Button background image and returns the button’s DOMRect.

<script setup lang="ts">
  import { ref, shallowRef } from 'vue'
  import { Button, Lightbox, type LightboxItem } from '@hina-ui/vue'

  const open = ref(false)
  const target = shallowRef<HTMLElement>()
  const items: LightboxItem[] = [
    {
      id: 'image-1',
      src: '/sample.webp',
      alt: 'A hillside on a summer afternoon',
      source: () => (target.value?.isConnected ? target.value.getBoundingClientRect() : undefined),
    },
  ]

  function show(event: MouseEvent) {
    target.value = event.currentTarget as HTMLElement
    open.value = true
  }
</script>

<template>
  <Button
    variant="ghost"
    tone="neutral"
    :ripple="false"
    aria-label="Preview background image"
    class="h-36 w-64 cursor-zoom-in rounded-none border-0 bg-cover bg-center bg-no-repeat p-0"
    :style="{ backgroundImage: 'url(/sample.webp)' }"
    @click="show"
  />
  <Lightbox v-model:open="open" :items="items" />
</template>
vue

Sizing and zoom

Images initially fit within the available area without exceeding their intrinsic size. The available area reserves space for the close button, toolbar and thumbnails, and is recalculated when the window changes size.

Double tapping switches between the initial and intrinsic sizes. Panoramas wider than 2:1 first fit the available height; tall images taller than 2:1 first fit the available width. Both are capped at intrinsic size. Use “Actual size” in the toolbar to go directly to 100%, or “Fit to window” to restore the initial fit.

Wheel, pinch and toolbar zoom can reach twice the intrinsic image size. The corresponding toolbar buttons are disabled at each limit. When a small image already appears at intrinsic size, double tapping does not enlarge it further; toolbar and gesture zoom remain available.

This example uses Image and ImageGroup to display images with different intrinsic dimensions.

128 × 128

256 × 256

1200 × 675

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

  const pictures = [
    { src: '/favicon.png', alt: 'Hina UI', size: '128 × 128' },
    { src: '/avatars/huh.webp', alt: 'Character portrait', size: '256 × 256' },
    { src: '/sample.webp', alt: 'A hillside on a summer afternoon', size: '1200 × 675' },
  ]
</script>

<template>
  <ImageGroup>
    <Inline align="start" class="gap-6">
      <Stack v-for="picture in pictures" :key="picture.src" gap="xs" class="w-32">
        <Image
          :src="picture.src"
          :alt="picture.alt"
          preview
          fit="contain"
          class="h-24 rounded-md"
        />
        <Text tone="muted" size="sm">{{ picture.size }}</Text>
      </Stack>
    </Inline>
  </ImageGroup>
</template>
vue

Preview dimensions

Set previewSize: { width, height } on an item to supply the final preview image’s intrinsic pixel dimensions. They describe preview when a separate larger image is provided, or src otherwise. Both dimensions must be finite and positive; omitted or invalid values retain automatic sizing.

Valid explicit dimensions determine the initial fit and zoom limit and are not replaced by decoded dimensions. Loading the larger image only replaces its pixels. Rectangle-source transitions can also start without waiting for decoding. These dimensions do not change the position or size returned by source.

The example wraps Image in a Button for its rectangle source and supplies the preview dimensions on the item. Image also accepts the same prop.

<script setup lang="ts">
  import { ref, shallowRef } from 'vue'
  import { Button, Image, Lightbox, type LightboxItem } from '@hina-ui/vue'

  const open = ref(false)
  const target = shallowRef<HTMLElement>()
  const image = {
    thumbnail: '/sample-thumbnail.webp',
    original: '/sample.webp',
    width: 1200,
    height: 675,
  }
  const items: LightboxItem[] = [
    {
      id: 'image-1',
      src: image.thumbnail,
      preview: image.original,
      previewSize: { width: image.width, height: image.height },
      alt: 'A hillside on a summer afternoon',
      source: () => (target.value?.isConnected ? target.value.getBoundingClientRect() : undefined),
    },
  ]

  function show(event: MouseEvent) {
    target.value = event.currentTarget as HTMLElement
    open.value = true
  }
</script>

<template>
  <Button
    variant="ghost"
    tone="neutral"
    :ripple="false"
    aria-label="Preview image"
    class="h-auto w-64 cursor-zoom-in rounded-none border-0 p-0"
    @click="show"
  >
    <Image
      :src="image.thumbnail"
      alt="A hillside on a summer afternoon"
      :ratio="16 / 9"
      :lazy="false"
      class="w-64"
    />
  </Button>
  <Lightbox v-model:open="open" :items="items" />
</template>
vue

Behaviour

  • src and preview are used as provided, including regular image addresses and valid blob URLs.
  • source is optional. A visible source image supplies the position and clipping shape for opening and closing. Without a source, the preview opens with a fade and a small scale transition.
  • preview supplies an optional larger image. Without a valid previewSize, its decoded dimensions determine the initial fit and zoom limit. Size changes animate after any opening transition finishes. If loading fails, src remains in use.
  • Zooming, panning, rotation, downloading and paging behave like Image previews.
  • The caller owns blob URLs and should keep them valid while the preview uses them.
  • The component exposes controlled state. An application can connect imperative calls through shared state and a mounted preview component.

Accessibility

  • The current item’s alt labels its image and the preview dialog.
  • Focus remains inside the preview while it is open, and page scrolling is locked.
  • Escape closes the preview; ← and → move between images.

API

Prop
Type
Default
Description
items
LightboxItem[]
Required
Image sequence
v-model:open
boolean
false
Whether the preview is open
v-model:index
number
0
Current image index, starting at zero
loop
boolean
false
Whether paging wraps at either end
class
string
—
Additional classes for the preview layer

LightboxItem

Field
Type
Description
id
string
Stable, unique identifier; use distinct identifiers for different images
src
string
Image address
alt
string
Image description and dialog name
preview
string
Optional larger image address
previewSize
{ width: number; height: number }
Intrinsic dimensions of the final preview image; both must be finite and positive
fit
ImageVariants['fit']
Source image fit, used to calculate the opening transition
source
() => LightboxSource | null | undefined
Optional function returning a source image or rectangle

LightboxSource

Exported from the package root, with two supported source forms.

type LightboxSource = HTMLImageElement | { x: number; y: number; width: number; height: number }
ts