Skip to content

cv-image-viewer

Fullscreen image viewer shell for modal image inspection, gallery navigation, actions, loading/error states, and thumbnail virtualization.

Usage

View source
html
<div class="image-viewer-demo-shell" data-demo="image-viewer" data-live-demo-height="760">
  <section class="image-viewer-demo-launch" aria-labelledby="image-viewer-demo-title">
    <div class="image-viewer-demo-copy">
      <span class="image-viewer-demo-kicker">Controlled image inspection</span>
      <h3 id="image-viewer-demo-title">Preview vault imagery before opening the modal viewer</h3>
      <p>
        The host owns source URLs, metadata, actions, and current index. The viewer emits navigation and
        action intent; the shell commits state back into the component.
      </p>
      <dl class="image-viewer-demo-proof">
        <div>
          <dt>5</dt>
          <dd>items</dd>
        </div>
        <div>
          <dt>3</dt>
          <dd>viewer actions</dd>
        </div>
        <div>
          <dt>0</dt>
          <dd>catalog state inside UI kit</dd>
        </div>
      </dl>
      <div class="image-viewer-demo-actions">
        <cv-button variant="primary" data-image-viewer-open>
          <cv-icon slot="prefix" name="maximize" size="s"></cv-icon>
          Open viewer
        </cv-button>
        <output data-image-viewer-output aria-live="polite"
          >Ready: chromvoid-mobile-vault.png selected</output
        >
      </div>
    </div>

    <div class="image-viewer-demo-stage" aria-label="Selected image preview">
      <figure class="image-viewer-demo-preview">
        <img data-image-viewer-preview alt="" width="960" height="540" />
        <figcaption>
          <span data-image-viewer-preview-title>chromvoid-mobile-vault.png</span>
          <span data-image-viewer-preview-meta>generated PNG / 1795 x 876 / mobile vault</span>
        </figcaption>
      </figure>
      <div class="image-viewer-demo-gallery" aria-label="Generated gallery images">
        <button type="button" class="image-viewer-demo-shot" data-image-viewer-index="0" aria-pressed="true">
          <img data-image-viewer-thumb="chromvoid-mobile-vault-thumb.png" alt="" width="320" height="180" />
          <span>Mobile vault</span>
        </button>
        <button type="button" class="image-viewer-demo-shot" data-image-viewer-index="1" aria-pressed="false">
          <img data-image-viewer-thumb="hardware-vault-keypad-thumb.png" alt="" width="320" height="180" />
          <span>Keypad</span>
        </button>
        <button type="button" class="image-viewer-demo-shot" data-image-viewer-index="2" aria-pressed="false">
          <img data-image-viewer-thumb="mobile-core-bridge-thumb.png" alt="" width="320" height="180" />
          <span>Core bridge</span>
        </button>
        <button type="button" class="image-viewer-demo-shot" data-image-viewer-index="3" aria-pressed="false">
          <img data-image-viewer-thumb="deniable-vault-cube-thumb.png" alt="" width="320" height="180" />
          <span>Vault cube</span>
        </button>
        <button type="button" class="image-viewer-demo-shot" data-image-viewer-index="4" aria-pressed="false">
          <img data-image-viewer-thumb="trust-orbit-thumb.png" alt="" width="320" height="180" />
          <span>Trust orbit</span>
        </button>
      </div>
    </div>
  </section>

  <cv-image-viewer current-index="0"></cv-image-viewer>
</div>

<script>
  document
    .querySelectorAll('.image-viewer-demo-shell[data-demo="image-viewer"]:not([data-ready])')
    .forEach((shell) => {
      shell.dataset.ready = 'true'

      const viewer = shell.querySelector('cv-image-viewer')
      const output = shell.querySelector('[data-image-viewer-output]')
      const preview = shell.querySelector('[data-image-viewer-preview]')
      const previewTitle = shell.querySelector('[data-image-viewer-preview-title]')
      const previewMeta = shell.querySelector('[data-image-viewer-preview-meta]')
      const parentUrl = window.parent?.location?.href || window.location.href
      const assetUrl = (file) => new URL(`../images/image-viewer/${file}`, parentUrl).href
      const items = [
        {
          id: 'chromvoid-mobile-vault',
          title: 'chromvoid-mobile-vault.png',
          alt: 'Generated ChromVoid mobile vault scene with layered vault tiles',
          meta: ['generated PNG', '1795 x 876', 'mobile vault'],
          src: assetUrl('chromvoid-mobile-vault.png'),
          thumbnailSrc: assetUrl('chromvoid-mobile-vault-thumb.png'),
        },
        {
          id: 'hardware-vault-keypad',
          title: 'hardware-vault-keypad.png',
          alt: 'Generated hardware vault keypad with a security token and cyan signal path',
          meta: ['generated PNG', '1731 x 909', 'hardware vault'],
          src: assetUrl('hardware-vault-keypad.png'),
          thumbnailSrc: assetUrl('hardware-vault-keypad-thumb.png'),
        },
        {
          id: 'mobile-core-bridge',
          title: 'mobile-core-bridge.png',
          alt: 'Generated phone connected to a self-hosted core device on a desktop',
          meta: ['generated PNG', '1672 x 941', 'device bridge'],
          src: assetUrl('mobile-core-bridge.png'),
          thumbnailSrc: assetUrl('mobile-core-bridge-thumb.png'),
        },
        {
          id: 'deniable-vault-cube',
          title: 'deniable-vault-cube.png',
          alt: 'Generated transparent vault cube with deniable layers and device connections',
          meta: ['generated PNG', '1734 x 907', 'deniable vault'],
          src: assetUrl('deniable-vault-cube.png'),
          thumbnailSrc: assetUrl('deniable-vault-cube-thumb.png'),
        },
        {
          id: 'trust-orbit',
          title: 'trust-orbit.png',
          alt: 'Generated trust orbit diagram with connected devices and a central vault core',
          meta: ['generated PNG', '1672 x 941', 'trust graph'],
          src: assetUrl('trust-orbit.png'),
          thumbnailSrc: assetUrl('trust-orbit-thumb.png'),
        },
      ]

      const setStatus = (message) => {
        if (output) output.value = message
      }
      const syncPreview = () => {
        const item = items[viewer.currentIndex]
        if (!item) return
        if (preview) {
          preview.src = item.src
          preview.alt = item.alt || ''
        }
        if (previewTitle) previewTitle.textContent = item.title
        if (previewMeta) previewMeta.textContent = item.meta.join(' / ')
        shell.querySelectorAll('[data-image-viewer-index]').forEach((control) => {
          const active = Number(control.dataset.imageViewerIndex) === viewer.currentIndex
          control.setAttribute('aria-pressed', String(active))
        })
      }
      const commitIndex = (index, message) => {
        viewer.currentIndex = Math.max(0, Math.min(index, items.length - 1))
        syncPreview()
        setStatus(message || `Selected ${items[viewer.currentIndex].title}`)
      }

      shell.querySelectorAll('[data-image-viewer-thumb]').forEach((image) => {
        image.src = assetUrl(image.dataset.imageViewerThumb)
      })

      viewer.items = items
      viewer.actions = [
        {value: 'inspect-source', label: 'Inspect source'},
        {value: 'share', label: 'Share'},
        {value: 'remove', label: 'Remove', dangerous: true},
      ]
      viewer.thumbnailWindow = {
        indices: [0, 1, 2, 3, 4],
        beforeCount: 0,
        afterCount: 0,
        thumbnailStepPx: 68,
      }
      syncPreview()

      shell.querySelectorAll('[data-image-viewer-open]').forEach((control) => {
        control.addEventListener('click', () => {
          viewer.open = true
          setStatus(`Viewing ${items[viewer.currentIndex].title}`)
        })
      })

      shell.querySelectorAll('[data-image-viewer-index]').forEach((control) => {
        control.addEventListener('click', () => {
          commitIndex(Number(control.dataset.imageViewerIndex))
        })
      })

      viewer.addEventListener('cv-input', (event) => {
        commitIndex(event.detail.index, `Navigation requested: ${items[event.detail.index].title}`)
      })
      viewer.addEventListener('cv-action', (event) => {
        setStatus(`${event.detail.value} requested for ${items[event.detail.index].title}`)
      })
      viewer.addEventListener('cv-close', () => {
        viewer.open = false
        setStatus('Viewer closed')
      })
    })
</script>

Anatomy

<cv-image-viewer> (host)
└── <cv-dialog no-header> (modal shell, role="dialog")
    ├── <span slot="title">current image title</span>
    └── <section part="base">
        ├── <header part="header">
        │   ├── <div part="title-group">
        │   │   ├── <div part="title">
        │   │   └── <div part="meta">
        │   └── <div part="header-actions">
        │       ├── action buttons or overflow menu
        │       └── close button
        ├── <main part="viewport-region">
        │   ├── <div part="viewport">
        │   │   └── <slot name="viewport">fallback image state / image-stage</slot>
        │   ├── previous/next controls
        │   ├── busy overlay
        │   └── <div part="overlay">
        │       └── <slot name="overlay">
        └── <footer part="footer">
            └── <slot name="footer">virtual thumbnail rail</slot>

Attributes

AttributeTypeDefaultDescription
openBooleanfalseWhether the modal viewer is visible
current-indexNumber0Selected item index
busyBooleanfalseShows a modal busy affordance above the viewport
busy-labelStringLoadingAccessible label for the busy affordance
chrome-visibleBooleantrueShows or hides header and footer chrome
layoutStringautodesktop, mobile, or responsive auto
show-thumbnailsBooleantrueEnables the built-in thumbnail rail fallback

Properties

PropertyTypeDescription
itemsCVImageViewerItem[]App-provided image records
actionsCVImageViewerAction[]App-provided action descriptors
thumbnailWindowCVImageViewerThumbnailWindow | nullVirtual thumbnail window for large galleries
ts
type CVImageViewerItem = {
  id: string | number
  title: string
  alt?: string
  meta?: readonly string[]
  src?: string | null
  thumbnailSrc?: string | null
  loading?: boolean
  error?: string | null
}

type CVImageViewerAction = {
  value: string
  label: string
  icon?: string
  // Destructive visual affordance only; consumers still own confirmation.
  dangerous?: boolean
  disabled?: boolean
  loading?: boolean
}

type CVImageViewerThumbnailWindow = {
  indices: number[]
  beforeCount: number
  afterCount: number
  thumbnailStepPx: number
}

Slots

SlotDescription
viewportReplaces the fallback image renderer with an app-owned renderer or track
footerReplaces the built-in thumbnail rail with an app-owned footer
overlayApp-owned overlay sheets or panels rendered above the viewport

CSS Parts

PartDescription
baseFullscreen viewer layout container
headerHeader chrome
title-groupTitle and metadata container
titleCurrent item title
metaCounter and metadata row
header-actionsActions and close control
viewport-regionMain viewport area
viewportFallback or slotted image viewport
image-stageBuilt-in current/outgoing image stack
imageBuilt-in fallback image
stateEmpty/loading/error state
nav nav-previousPrevious image control
nav nav-nextNext image control
busy-overlayBusy overlay
busy-statusBusy status chip
overlaySlotted overlay layer
footerFooter chrome
thumbnailsBuilt-in thumbnail rail
thumbnailBuilt-in thumbnail button
thumbnail-window-spacerVirtual-window before/after count marker
thumbnail-placeholderThumbnail fallback label

Events

EventDetailDescription
cv-close{reason: 'control' | 'escape' | 'backdrop'}User requested close
cv-input{index, itemId, direction, source}User requested a navigation index
cv-change{index, itemId, direction, source}External currentIndex commit observed
cv-action{value, itemId, index}User invoked an action
cv-image-error{itemId, index, sourceUrl}Built-in fallback image failed to render
cv-thumbnail-metrics{viewportWidth, thumbnailStepPx, centerIndex}Built-in thumbnail rail metrics changed
cv-prime{index, itemId, reason: 'open' | 'navigation' | 'thumbnail'}Viewer requests resource priming

cv-image-viewer never fetches app files or owns catalog state. Consumers provide source URLs, loading/error state, actions, and thumbnail windows. The viewer is controlled: navigation controls emit cv-input, consumers commit currentIndex, then the viewer emits cv-change for the committed prop change.

The built-in fallback viewport animates committed image changes with a directional slide/fade based on the navigation direction. Slotted viewport content remains fully consumer-owned. The transition respects prefers-reduced-motion.

Navigation source values are control, gesture, keyboard, thumbnail, or programmatic. On desktop, the built-in viewport treats a clearly horizontal wheel gesture as touchpad gallery navigation: one continuous swipe emits at most one cv-input request, while vertical wheel input and zoom/meta gestures are ignored.

Keyboard

KeyBehavior
EscapeEmits cv-close with escape
ArrowLeftEmits cv-input for previous item
ArrowRightEmits cv-input for next item
HomeEmits cv-input for first item
EndEmits cv-input for last item
TabRemains trapped by cv-dialog

ChromVoid UIKit documentation