Skip to content

EpLazyImage

Props

NameDescriptionTypeDefault
srcThe source URL of the image.string-
altThe alt text for the image.string''
widthThe width of the image.`stringnumber`
heightThe height of the image.`stringnumber`
aspectRatioThe aspect ratio of the image (e.g., '16 / 9').string'16 / 10'
objectFitHow the image fits within its container.string'contain'
classNameAdditional CSS class name for the image element.string''
placeholderURL of the placeholder image to display while loading.string''
placeholderColorThe background color of the placeholder.string'#f5f5f5'
placeholderOpacityThe opacity of the placeholder.number1
lazyIf true, enables lazy loading using Intersection Observer.booleantrue
roundedIf true, applies rounded corners to the image.booleantrue
rootMarginThe root margin for the Intersection Observer (controls when loading starts).string'0px 0px 100px 0px'

INFO

This component does not use events, slots.

Component Code

vue
<template>
  <div
    ref="imageEl"
    :class="['ep-image', { 'ep-image--rounded': rounded, 'ep-image--loading': !isLoaded }]"
  >
    <img
      v-if="isLoaded"
      :src="src"
      :alt="alt"
      :width="width"
      :height="height"
      :class="['ep-image__img', className]"
      :style="{ aspectRatio: aspectRatio, objectFit: objectFit }"
    >
    <div
      v-else
      class="ep-image__placeholder"
      :style="placeholderStyle"
    />
  </div>
</template>

<script setup>
  import { computed, onBeforeUnmount, onMounted, ref } from 'vue'

  const props = defineProps({
    /**
     * The source URL of the image.
     */
    src: {
      type: String,
      required: true,
    },
    /**
     * The alt text for the image.
     */
    alt: {
      type: String,
      default: '',
    },
    /**
     * The width of the image.
     */
    width: {
      type: [String, Number],
      default: '100%',
    },
    /**
     * The height of the image.
     */
    height: {
      type: [String, Number],
      default: '100%',
    },
    /**
     * The aspect ratio of the image (e.g., '16 / 9').
     */
    aspectRatio: {
      type: String,
      default: '16 / 10',
    },
    /**
     * How the image fits within its container.
     * @values contain, cover, fill, none, scale-down
     */
    objectFit: {
      type: String,
      default: 'contain',
    },
    /**
     * Additional CSS class name for the image element.
     */
    className: {
      type: String,
      default: '',
    },
    /**
     * URL of the placeholder image to display while loading.
     */
    placeholder: {
      type: String,
      default: '',
    },
    /**
     * The background color of the placeholder.
     */
    placeholderColor: {
      type: String,
      default: '#f5f5f5',
    },
    /**
     * The opacity of the placeholder.
     */
    placeholderOpacity: {
      type: Number,
      default: 1,
    },
    /**
     * If true, enables lazy loading using Intersection Observer.
     */
    lazy: {
      type: Boolean,
      default: true,
    },
    /**
     * If true, applies rounded corners to the image.
     */
    rounded: {
      type: Boolean,
      default: true,
    },
    /**
     * The root margin for the Intersection Observer (controls when loading starts).
     */
    rootMargin: {
      type: String,
      default: '0px 0px 100px 0px',
    },
  })

  const isLoaded = ref(false)
  const imageEl = ref(null)
  let observer = null

  const placeholderStyle = computed(() => {
    return {
      width: props.width,
      height: props.height,
      aspectRatio: props.aspectRatio,
      backgroundColor: props.placeholderColor,
      opacity: props.placeholderOpacity,
      backgroundImage: props.placeholder ? `url(${props.placeholder})` : '',
      backgroundSize: 'cover',
    }
  })

  const addLazyLoadListener = () => {
    observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          loadImage()
          observer.unobserve(entry.target)
        }
      })
    }, {
      rootMargin: props.rootMargin,
      threshold: 0.1,
    })

    if (imageEl.value) {
      observer.observe(imageEl.value)
    }
  }

  const loadImage = () => {
    isLoaded.value = true
  }

  onMounted(() => {
    if (props.lazy) {
      addLazyLoadListener()
    } else {
      loadImage()
    }
  })

  onBeforeUnmount(() => {
    if (observer) {
      observer.disconnect()
      observer = null
    }
  })
</script>

<style lang="scss" scoped>
  .ep-image {
    position: relative;
    display: block;
    overflow: hidden;
    max-width: 100%;

    &--rounded {
      border-radius: var(--border-radius--large);
    }

    &--loading {
      background: var(--interface-surface);

      &::after {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        animation: shimmer 1.5s infinite;
        background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
        content: '';
      }
    }
  }

  .ep-image__img {
    display: block;
    width: 100%;
    height: auto;
    opacity: 0;
    transition: opacity 0.4s ease-in-out;
    animation: fadeIn 0.4s ease-in-out forwards;
  }

  .ep-image__placeholder {
    position: absolute;
    top: 0;
    left: 0;
  }

  @keyframes fadeIn {
    to {
      opacity: 1;
    }
  }

  @keyframes shimmer {
    0% {
      transform: translateX(-100%);
    }

    100% {
      transform: translateX(100%);
    }
  }
</style>