Patterns · Settings
Image dropzone
The full-width settings image field where the image IS the field — a dashed upload card with empty / loading / filled states and a Change/Edit/Delete overlay; it emits intents, the host owns the modals (SETTINGS-44).
Examples
State
Empty — full-width dashed upload card (the image IS the field)
Loading (same height — no jump) · filled contain · filled cover (hover for the Change/Edit/Delete overlay)
<!-- Paste-and-run: save as .html and open in a browser. No build step.
<aha-image-dropzone> is the SAME shared custom element React and Vue consume — here in its native
form. Theming comes only from the --aha-* tokens in tokens.css. It EMITS intents
(add/change/edit/delete) — the host runs the upload/crop modals; this field owns none. -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/tokens.css">
<script type="module">
import 'https://cdn.jsdelivr.net/gh/ahaslides-product/ahaslides-design@master/lib/aha-image-dropzone.js'; // registers <aha-image-dropzone>
</script>
<div style="max-width:420px">
<aha-image-dropzone id="bg" state="empty" label="Add background image"></aha-image-dropzone>
</div>
<script type="module">
const el = document.getElementById('bg');
// The host owns the flow: on `add`, open your uploader, flip to loading, then filled + src.
el.addEventListener('add', () => {
el.state = 'loading';
setTimeout(() => { el.src = 'https://picsum.photos/640/240'; el.state = 'filled'; }, 900);
});
el.addEventListener('delete', () => { el.removeAttribute('src'); el.state = 'empty'; });
</script>
import '@ahaslides-product/design/aha-image-dropzone'; // registers <aha-image-dropzone>
import { useRef, useEffect } from 'react';
// React 18 needs a thin wrapper; React 19 can use <aha-image-dropzone> directly.
// It emits intents; the host owns the modals — drive state/src from your upload flow.
function AhaImageDropzone({ state, src, label, disabled, onAdd, onChange, onEdit, onDelete }) {
const ref = useRef();
useEffect(() => {
const el = ref.current;
const map = { add: onAdd, change: onChange, edit: onEdit, delete: onDelete };
const on = Object.entries(map).map(([k, fn]) => { const h = () => fn?.(); el.addEventListener(k, h); return [k, h]; });
return () => on.forEach(([k, h]) => el.removeEventListener(k, h));
}, [onAdd, onChange, onEdit, onDelete]);
return (
<aha-image-dropzone
ref={ref}
state={state || 'empty'}
{...(src ? { src } : {})}
{...(label ? { label } : {})}
{...(disabled ? { disabled: '' } : {})}
/>
);
}
// usage — the host runs the uploader and flips state
<AhaImageDropzone
state={bg ? 'filled' : 'empty'}
src={bg}
label="Add background image"
onAdd={openUploader}
onDelete={() => setBg(null)}
/>
// main.ts — register the element + mark aha-* as custom elements
import '@ahaslides-product/design/aha-image-dropzone'; // registers <aha-image-dropzone>
app.config.compilerOptions.isCustomElement = (tag) => tag.startsWith('aha-');
// Component.vue
<script setup>
import { ref } from 'vue';
const bg = ref(null);
const state = ref('empty');
function onAdd() { state.value = 'loading'; upload().then((url) => { bg.value = url; state.value = 'filled'; }); }
function onDelete() { bg.value = null; state.value = 'empty'; }
</script>
<template>
<!-- emits intents; the host owns the upload/crop modals -->
<aha-image-dropzone
:state="state"
:src="bg || undefined"
label="Add background image"
@add="onAdd"
@edit="openCropper"
@delete="onDelete"
/>
</template>
API
| Prop | Type | Default | Notes |
|---|---|---|---|
state | empty | loading | filled | empty | The field's state — drives which affordance shows, at a constant box height |
src | string | — | The image URL when filled |
fit | contain | cover | contain | How the filled image sits in the box — contain (whole image, letterboxed) or cover (fill, cropped) |
label | string | Add image | The empty-state prompt text |
disabled | boolean | false | Non-interactive; grey fill |
add / change / edit / delete | CustomEvent | — | Composed intents — the host runs the corresponding modal/flow |
Install
# .npmrc — once: point the @ahaslides-product scope at GitHub Packages
@ahaslides-product:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} # a GitHub token with read:packages
npm i @ahaslides-product/design
import '@ahaslides-product/design/tokens.css'; // once, at the app root
import '@ahaslides-product/design/aha-image-dropzone'; // registers <aha-image-dropzone>
Agent feed for this component (absolute, fetchable anywhere): image-dropzone.agent.json · image-dropzone.md · image-dropzone.llms.txt
When to use
When to use
- Image dropzone — a standalone MAIN image field — a background/hero image, an interactive-image base, a picture reveal (SETTINGS-44)
- Image action button — a per-OPTION image cell in an option row
The MAIN image field is this dropzone — NOT a hand-rolled "Add image"/"Replace image" button plus a separate "Remove image" danger button (SETTINGS-44). The loading state matches the empty card's height so the panel doesn't jump. It emits intents; the host owns the upload/crop modals.
Surfaces
editor settings
Spec
Zone full-width, min-height 132, radius 8 · Empty 1px dashed #E3E3E3 card, centred system-image-square + label; click emits `add` · Loading spinner at the SAME 132 min-height — the panel does not jump · Filled image fitted (fit=contain default | cover) + hover Change/Edit/Delete overlay; solid border · Intents add · change · edit · delete — host owns the modals, this owns none · Hover #D3B4FF border + #6A1EBB label/icon · Disabled #F1F1F1 fill, #EBEBEB border, not-allowed