diff --git a/README.md b/README.md
index d0f8bfa..3a44819 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,7 @@ The component works out of the box in React Server Components environments (e.g.
| `style` | `CSSProperties` | Styles applied to the container div. Overrides the default `flex: 1`. |
| `wrapperStyle` | `CSSProperties` | Styles applied to the outer wrapper div, which owns `minHeight` and the flex layout. Set this to drop the editor into a non-flex layout. |
| `ariaLabel` | `string` | Accessible name for the editor region. Defaults to `'Image editor'`. |
+| `placeholder` | `ReactNode` | Rendered centred over the container until the editor mounts — a spinner or skeleton for the embed's cold-cache load. Stays visible if the mount fails. |
| `onLoad` | `(editor) => void` | Called with the editor instance once it is mounted. |
| `onSave` | `({ dataUrl, blob }) => void` | Called when the user saves the edited image. |
| `onCancel` | `() => void` | Called when the user cancels editing. |
@@ -88,6 +89,16 @@ const dataUrl = editorRef.current?.editor?.getImage();
| Any other `options` key | Full remount — the editor is destroyed and recreated with the new configuration. |
| `onSave` / `onCancel` / `onLoadError` / `onLoad` / `onError` | Always call the latest handler; changing them never remounts. |
+## Loading state
+
+The embed script and its versioned bundle are fetched at mount. On a cold cache that is a visible gap, so pass a `placeholder` to fill it:
+
+```jsx
+} />
+```
+
+It is rendered centred over the container and removed once the editor is live. It is **not** removed when the mount fails, so a failed load never leaves a blank box — pair it with `onError` to swap in a failure message.
+
## Error handling
Two distinct channels:
diff --git a/src/ImageEditor.tsx b/src/ImageEditor.tsx
index acae488..22c3ae6 100644
--- a/src/ImageEditor.tsx
+++ b/src/ImageEditor.tsx
@@ -21,6 +21,7 @@ function ImageEditorInner(
style = {},
wrapperStyle = {},
ariaLabel = 'Image editor',
+ placeholder,
} = props;
const [editor, setEditor] = useState(null);
@@ -202,12 +203,20 @@ function ImageEditorInner(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor, updatableKey]);
+ // Rendered as an overlay sibling rather than a child of the mount
+ // container: the embed owns that container's DOM, and React must not
+ // reconcile children in and out from under it.
+ const showPlaceholder = placeholder !== undefined && editor === null;
+
return (
@@ -222,6 +231,19 @@ function ImageEditorInner(
// flex first: a default the consumer's style can override.
style={{ flex: 1, ...style }}
/>
+ {showPlaceholder && (
+
+ {placeholder}
+
+ )}
);
}
diff --git a/src/types.ts b/src/types.ts
index fb1b831..a036598 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,4 +1,4 @@
-import { CSSProperties } from 'react';
+import { CSSProperties, ReactNode } from 'react';
import type {
Features,
@@ -113,6 +113,13 @@ export interface ImageEditorProps {
* and no name of its own. Defaults to 'Image editor'.
*/
ariaLabel?: string;
+ /**
+ * Rendered centred over the container until the editor is mounted — a
+ * spinner or skeleton for the embed's cold-cache load. Stays visible if
+ * the mount fails, so a blank box is never the end state; pair it with
+ * `onError` to swap in a failure message.
+ */
+ placeholder?: ReactNode;
/**
* Override the embed script URL (e.g. to pin an environment). One embed
* per page: the first loader to run installs window.ImageEditor and wins
diff --git a/test/index.test.tsx b/test/index.test.tsx
index 909c577..006b2cb 100644
--- a/test/index.test.tsx
+++ b/test/index.test.tsx
@@ -400,6 +400,73 @@ it('gives the editor region an accessible name, overridable via ariaLabel', asyn
expect(container.getAttribute('aria-label')).toBe('Avatar cropper');
});
+it('shows the placeholder until the editor mounts, then removes it', async () => {
+ const created = defer();
+ createEditor.mockReturnValueOnce(created.promise);
+
+ render(Loading…
} />);
+
+ // Visible for the whole embed load + createEditor round trip.
+ expect(document.body.textContent).toContain('Loading…');
+
+ await act(async () => {
+ created.resolve(mockInstance as unknown as ImageEditorInstance);
+ });
+
+ expect(document.body.textContent).not.toContain('Loading…');
+});
+
+it('renders no placeholder element when the prop is absent', async () => {
+ const { container } = render();
+ await flush();
+
+ const wrapper = container.firstElementChild as HTMLElement;
+ // Just the mount container — no overlay, and no positioning added.
+ expect(wrapper.children).toHaveLength(1);
+ expect(wrapper.style.position).toBe('');
+});
+
+it('keeps the placeholder visible when the mount fails', async () => {
+ createEditor.mockRejectedValueOnce(new Error('bundle 404'));
+
+ render(
+ Loading…}
+ onError={vi.fn()}
+ />
+ );
+ await flush();
+
+ // A failed mount must not leave a blank box behind.
+ expect(document.body.textContent).toContain('Loading…');
+});
+
+it('brings the placeholder back while a remount is in flight', async () => {
+ const { rerender } = render(
+ Loading…} />
+ );
+ await flush();
+ expect(document.body.textContent).not.toContain('Loading…');
+
+ const second = defer();
+ createEditor.mockReturnValueOnce(second.promise);
+ rerender(
+ Loading…}
+ options={{ projectId: 1 }}
+ />
+ );
+
+ expect(document.body.textContent).toContain('Loading…');
+
+ await act(async () => {
+ second.resolve(makeInstance() as unknown as ImageEditorInstance);
+ });
+ expect(document.body.textContent).not.toContain('Loading…');
+});
+
it('passes onCancel and onLoadError through to the editor', async () => {
const onCancel = vi.fn();
const onLoadError = vi.fn();