Menu

Change language

GitHub repo

Lazy Image

lazy-imageui

Lazy-loaded image with aspect-ratio reservation, skeleton placeholder, fade-in transition, and error fallback. Composes loading="lazy" with IntersectionObserver for off-viewport hold and pairs with the skeleton primitive for the placeholder state.

Installation

$npx shadcn@latest add https://uipkge.dev/r/lit/lazy-image.json
Manifest:https://uipkge.dev/r/lit/lazy-image.jsonInstalls to:src/uip/components/lazy-image/

Examples

Loading interactive previews…

Props

NameType / ValuesDefaultRequired
srcstring—optional
srcsetstring—optional
sizesstring—optional
altstring—optional
aspect-ratiostring | number—optional
widthstring | number—optional
heightstring | number—optional
placeholderImagePlaceholderskeletonoptional
coverstringtrueoptional
eagerbooleanfalseoptional
fallbackstring—optional
transitionstringtrueoptional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

HTMLElementTagNameMap
interface HTMLElementTagNameMap {
    'uip-lazy-image': UipLazyImage
  }
}

npm dependencies

Includes

Files installed (1)

  • src/uip/components/lazy-image/lazy-image.ts6.1 kB
    import { LitElement, css, html, isServer, nothing } from 'lit'
    import { cn } from '../../lib/utils'
    import { tailwind } from '../../lib/styles'
    import { ThemeController } from '../../lib/theme'
    
    export type ImagePlaceholder = 'skeleton' | 'none'
    
    const trueByDefault = {
      fromAttribute: (v: string | null) => v !== 'false' && v !== null,
      toAttribute: (v: boolean) => (v ? '' : 'false'),
    }
    
    /**
     * <uip-lazy-image> — progressive image loader with skeleton placeholder and fallback.
     */
    export class UipLazyImage extends LitElement {
      static styles = [
        tailwind,
        css`
          :host {
            display: block;
            overflow: hidden;
            position: relative;
          }
        `,
      ]
    
      static properties = {
        src: { reflect: true },
        srcSet: { attribute: 'srcset', reflect: true },
        sizes: { reflect: true },
        alt: { reflect: true },
        aspectRatio: { attribute: 'aspect-ratio', reflect: true },
        width: { reflect: true },
        height: { reflect: true },
        placeholder: { reflect: true },
        cover: { converter: trueByDefault, reflect: true },
        eager: { type: Boolean, reflect: true },
        fallback: { reflect: true },
        transition: { converter: trueByDefault, reflect: true },
        state: { state: true },
        visible: { state: true },
      }
    
      src = ''
      srcSet?: string
      sizes?: string
      alt = ''
      aspectRatio?: string | number
      width?: string | number
      height?: string | number
      placeholder: ImagePlaceholder = 'skeleton'
      cover = true
      eager = false
      fallback?: string
      transition = true
    
      private state: 'idle' | 'loading' | 'loaded' | 'error' = 'idle'
      private visible = false
      private observer?: IntersectionObserver
    
      constructor() {
        super()
        new ThemeController(this)
      }
    
      connectedCallback() {
        super.connectedCallback()
        this.setAttribute('data-uipkge', '')
        this.setAttribute('data-slot', 'lazy-image')
        this.setupObserver()
      }
    
      disconnectedCallback() {
        super.disconnectedCallback()
        if (this.observer) {
          this.observer.disconnect()
          this.observer = undefined
        }
      }
    
      protected updated(changedProps: Map<string, unknown>) {
        super.updated(changedProps)
        if (changedProps.has('src') || changedProps.has('eager')) {
          this.state = 'idle'
          this.setupObserver()
        }
        if (this.visible && this.state === 'idle') {
          this.state = 'loading'
        }
      }
    
      private setupObserver() {
        if (isServer) {
          this.visible = true
          return
        }
    
        if (this.eager) {
          this.visible = true
          return
        }
    
        this.visible = false
    
        if (typeof IntersectionObserver === 'undefined') {
          this.visible = true
          return
        }
    
        if (this.observer) {
          this.observer.disconnect()
        }
    
        this.observer = new IntersectionObserver(
          (entries) => {
            for (const entry of entries) {
              if (entry.isIntersecting) {
                this.visible = true
                this.observer?.disconnect()
                this.observer = undefined
                break
              }
            }
          },
          { rootMargin: '200px' },
        )
        this.observer.observe(this)
      }
    
      private onImgLoad(e: Event) {
        this.state = 'loaded'
        this.dispatchEvent(new CustomEvent('load', { bubbles: true, composed: true }))
      }
    
      private onImgError(e: Event) {
        this.state = 'error'
        this.dispatchEvent(new CustomEvent('error', { bubbles: true, composed: true }))
      }
    
      render() {
        const styleParts: string[] = []
        if (this.aspectRatio !== undefined) {
          const ar = typeof this.aspectRatio === 'number' ? String(this.aspectRatio) : this.aspectRatio
          styleParts.push(`aspect-ratio: ${ar};`)
        }
        if (this.width !== undefined) {
          const w = typeof this.width === 'number' ? `${this.width}px` : this.width
          styleParts.push(`width: ${w};`)
        }
        if (this.height !== undefined) {
          const h = typeof this.height === 'number' ? `${this.height}px` : this.height
          styleParts.push(`height: ${h};`)
        }
        const containerStyle = styleParts.join(' ')
    
        return html`
          <div
            part="base"
            data-slot="lazy-image"
            class="bg-muted relative size-full overflow-hidden"
            style=${containerStyle || nothing}
          >
            ${this.placeholder === 'skeleton' && this.state !== 'loaded' && this.state !== 'error'
              ? html`<uip-skeleton class="absolute inset-0 size-full rounded-none"></uip-skeleton>`
              : nothing}
            ${this.visible && this.state !== 'error'
              ? html`
                  <img
                    src=${this.src}
                    srcset=${this.srcSet ?? nothing}
                    sizes=${this.sizes ?? nothing}
                    alt=${this.alt}
                    loading=${this.eager ? 'eager' : 'lazy'}
                    decoding=${this.eager ? 'sync' : 'async'}
                    class=${cn(
                      'block size-full',
                      this.cover ? 'object-cover' : 'object-contain',
                      this.transition && 'transition-opacity duration-300',
                      this.state === 'loaded' ? 'opacity-100' : 'opacity-0',
                    )}
                    @load=${this.onImgLoad}
                    @error=${this.onImgError}
                  />
                `
              : nothing}
            ${this.state === 'error'
              ? html`
                  <slot name="fallback">
                    ${this.fallback
                      ? html`
                          <img
                            src=${this.fallback}
                            alt=${this.alt}
                            class=${cn('block size-full', this.cover ? 'object-cover' : 'object-contain')}
                          />
                        `
                      : html`
                          <div
                            role="img"
                            class="text-muted-foreground absolute inset-0 flex items-center justify-center text-xs"
                            aria-label="Image failed to load"
                          >
                            <span aria-hidden="true">Image unavailable</span>
                          </div>
                        `}
                  </slot>
                `
              : nothing}
          </div>
        `
      }
    }
    
    customElements.get('uip-lazy-image') || customElements.define('uip-lazy-image', UipLazyImage)
    
    declare global {
      interface HTMLElementTagNameMap {
        'uip-lazy-image': UipLazyImage
      }
    }
    

Raw manifest:https://uipkge.dev/r/lit/lazy-image.json