Repository: nuxt/image (ts)

Issue:
Placeholder slot for `<NuxtImg>`
I've been thinking of how to achieve more sophisticated placeholders using `<NuxtImg>` than what the `placeholder` prop allows for. Being able to define the placeholder in a slot would allow for greater flexibility:
```
<NuxtImg>
  <template #placeholder>
    <SkeletonLoader />
  </template>
</NuxtImg>
```
If this is a desirable addition, I am willing to implement it.

Pull request description:
feat(nuxt-img): add `custom` slot for full control of rendering
### 🔗 Linked issue

resolves #1307

### ❓ Type of change

- [ ] 📖 Documentation (updates to the documentation or readme)
- [ ] 🐞 Bug fix (a non-breaking change that fixes an issue)
- [x] 👌 Enhancement (improving an existing functionality)
- [x] ✨ New feature (a non-breaking change that adds functionality)
- [ ] 🧹 Chore (updates to the build process or auxiliary tools and libraries)
- [ ] ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

### 📚 Description

This PR add a placeholder slot for NuxtImg component


Patch:
diff --git a/docs/content/2.usage/1.nuxt-img.md b/docs/content/2.usage/1.nuxt-img.md
index c2cf836..7a49770 100644
--- a/docs/content/2.usage/1.nuxt-img.md
+++ b/docs/content/2.usage/1.nuxt-img.md
@@ -36,6 +36,45 @@ With [default provider](/get-started/providers), you should put `/nuxt-icon.png`
 
 ## Props
 
+### `custom`
+
+The `custom` prop determines whether `<NuxtImg>` should render as a simple `<img>` element or only serve as a provider for custom rendering. When set to `true`, it disables the default rendering behavior, allowing full control over how the image is displayed. This is useful for implementing custom functionalities, such as placeholders.
+
+When using the `custom` prop, `<NuxtImg>` passes necessary data and attributes to its default slot. You can access the following values via the `v-slot` directive:
+
+- **`imgAttrs`**: Attributes for the `<img>` element (e.g., `alt`, `width`, `height`, `srcset`, `sizes`).
+- **`src`**: The computed image source URL.
+- **`isLoaded`**: A boolean indicating whether the image has been loaded.
+
+#### Example Usage
+
+```html
+<nuxt-img
+  src="/images/nuxt.png"
+  alt="image"
+  width="400" 
+  height="400" 
+  :custom="true"
+  v-slot="{ src, isLoaded, imgAttrs }"
+>
+  <!-- Show the actual image when loaded -->
+  <img 
+    v-if="isLoaded" 
+    v-bind="imgAttrs" 
+    :src="src" 
+  />
+
+  <!-- Show a placeholder while loading -->
+  <img 
+    v-else 
+    src="https://placehold.co/400x400" 
+    alt="placeholder" 
+  />
+</nuxt-img>
+```
+
+This approach ensures flexibility for custom rendering scenarios, while `<NuxtImg>` continues to handle image optimization and data provisioning behind the scenes.
+
 ### `src`
 
 Path to image file
@@ -114,6 +153,8 @@ To generate special versions of images for screens with increased pixel density.
 
 Display a placeholder image before the actual image is fully loaded.
 
+You can also use the [custom prop](/usage/nuxt-img#custom) to make any placeholder you want.
+
 The placeholder prop can be either a string, a boolean, a number, or an array. The usage is shown below for each case.
 
 ```html
diff --git a/src/runtime/components/NuxtImg.vue b/src/runtime/components/NuxtImg.vue
index c8132c4..a65f514 100644
--- a/src/runtime/components/NuxtImg.vue
+++ b/src/runtime/components/NuxtImg.vue
@@ -1,5 +1,6 @@
 <template>
   <img
+    v-if="!custom"
     ref="imgEl"
     :class="props.placeholder && !placeholderLoaded ? props.placeholderClass : undefined"
     v-bind="{
@@ -9,6 +10,18 @@
     }"
     :src="src"
   >
+  <slot
+    v-else
+    v-bind="{
+      ...isServer ? { onerror: 'this.setAttribute(\'data-error\', 1)' } : {},
+      imgAttrs: {
+        ...imgAttrs,
+        ...attrs,
+      },
+      isLoaded: placeholderLoaded,
+      src,
+    }"
+  />
 </template>
 
 <script setup lang="ts">
@@ -135,7 +148,7 @@ const nuxtApp = useNuxtApp()
 const initialLoad = nuxtApp.isHydrating
 
 onMounted(() => {
-  if (placeholder.value) {
+  if (placeholder.value || props.custom) {
     const img = new Image()
 
     if (mainSrc.value) {
diff --git a/src/runtime/components/_base.ts b/src/runtime/components/_base.ts
index dadf785..742d612 100644
--- a/src/runtime/components/_base.ts
+++ b/src/runtime/components/_base.ts
@@ -134,4 +134,5 @@ export const imgProps = {
   ...baseImageProps,
   placeholder: { type: [Boolean, String, Number, Array], required: false },
   placeholderClass: { type: String, required: false },
+  custom: { type: Boolean, required: false },
 }


Test patch:
diff --git a/test/unit/image.test.ts b/test/unit/image.test.ts
index a0014a2..518c869 100644
--- a/test/unit/image.test.ts
+++ b/test/unit/image.test.ts
@@ -376,4 +376,52 @@ describe('Renders image, applies module config', () => {
   })
 })
 
+describe('Renders NuxtImg with the custom prop and default slot', () => {
+  let wrapper: VueWrapper<any>
+  const src = '/image.png'
+
+  it('renders custom placeholder when image is not loaded', async () => {
+    const {
+      resolve: resolveImage,
+      loadEvent,
+    } = getImageLoad(() => {
+      wrapper = mount(NuxtImg, {
+        propsData: {
+          width: 200,
+          height: 200,
+          src: src,
+          custom: true,
+        },
+        slots: {
+          default: ({ imgAttrs, src, isLoaded }) => {
+            const img = h('img', { ...imgAttrs, src })
+            const placeholder = h('p', {}, 'placeholder')
+
+            return isLoaded
+              ? img
+              : placeholder
+          },
+        },
+      })
+    })
+
+    const placeholderText = wrapper.find('p').element.getHTML()
+    let img = wrapper.find('img')
+
+    expect(placeholderText).toMatchInlineSnapshot('"placeholder"')
+    expect(img.exists()).toBeFalsy()
+
+    resolveImage()
+    await nextTick()
+
+    img = wrapper.find('img')
+    const domSrc = img.element.getAttribute('src')
+
+    expect(img.element.getAttribute('width')).toBe('200')
+    expect(img.element.getAttribute('height')).toBe('200')
+    expect(domSrc).toMatchInlineSnapshot('"/_ipx/s_200x200/image.png"')
+    expect(wrapper.emitted().load![0]).toStrictEqual([loadEvent])
+  })
+})
+
 const mountImage = (props: ComponentMountingOptions<typeof NuxtImg>['props']) => mount(NuxtImg, { props })