Image & Banner Uploaders
Dialog-based image upload with crop for avatars/logos and org banners.
Uploader family for profile avatars, team/org logos, and organization banners. Both variants share the same dialog + file-pick + crop pipeline under packages/features/components/image-uploader. For an AI image that is still generating, use AI image placeholder instead of an uploader empty state.
import { ImageUploader, BannerUploader } from "@calcom/features/components/image-uploader";Ownership
Feature โ not @coss/ui/shared. Crop pipeline (react-easy-crop), i18n toasts/labels, 5MB file limit, and product size contracts (1:1 avatar vs 3:1 banner at caller height/width) keep these Feature-owned. Document both variants together; do not relocate for docs.
ImageUploader
Square avatar/logo upload. Production crops 1:1 in a circular cropper with zoom slider, then returns a data URL via handleAvatarChange.
Use when
- Uploading a profile avatar or team/organization logo.
- The caller needs Cal's file-size validation, circular crop, zoom, and encoded data URL contract.
- The surrounding surface owns preview composition, persistence, and the remove action.
Do not import react-easy-crop or recreate the dialog/canvas pipeline in a caller. Import ImageUploader and compose it with the appropriate preview.
Canonical profile composition
The profile page shows UserAvatar initials when no image is set, overlays an organization logo when the user belongs to one, and only renders Remove after the form value contains an image.
<Controller
control={formMethods.control}
name="avatarUrl"
render={({ field: { value, onChange } }) => {
const showRemoveButton = value !== null;
return (
<>
<UserAvatar
data-testid="profile-upload-avatar"
className="size-16"
name={user.name}
alt={user.name}
src={getUserAvatarUrl({ avatarUrl: value })}
previewSrc={value}
organizationSrc={
user.profile?.organization
? getPlaceholderAvatar(
user.profile.organization.logoUrl,
user.profile.organization.slug
)
: undefined
}
/>
<div className="flex flex-col gap-2">
<Label className="text-sm">{t("profile_picture")}</Label>
<div className="flex items-center gap-2">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("upload_avatar")}
handleAvatarChange={(newAvatar) => onChange(newAvatar)}
imageSrc={getUserAvatarUrl({ avatarUrl: value })}
/>
{showRemoveButton && (
<Button variant="ghost" size="sm" onClick={() => onChange(null)}>
{t("remove")}
</Button>
)}
</div>
</div>
</>
);
}}
/>API
| Prop | Type | Notes |
|---|---|---|
id | string | File input name |
buttonMsg | string | Trigger label |
handleAvatarChange | (imageSrc: string) => void | Cropped data URL after save |
target | string | Interpolated into upload dialog title |
imageSrc | string | Current preview in the dialog |
fallback | ReactNode | Avatar fallback when empty |
uploadInstruction | string | Optional helper under choose-file |
disabled | boolean | Disables trigger |
testId | string | Prefixes dialog/filechooser/upload test ids |
buttonSize / triggerVariant | Button props | Trigger styling |
BannerUploader
Wide banner upload for organizations. Production crops 3:1 and encodes to the callerโs width ร height (typically 1500 ร 500).
Use when
- Uploading an organization banner with the standard 3:1 crop contract.
- The caller can supply explicit output
widthandheight. - The surrounding surface owns the empty state, preview, persistence, and remove action.
Canonical organization composition
The organization profile mounts a hidden uploader inside the empty OrgBanner fallback so the visible in-plane button opens the standard dialog. Once a banner exists, the fallback unmounts and the uploader plus remove action render below the image.
<Controller
control={form.control}
name="banner"
render={({ field: { value, onChange } }) => {
const showRemoveBannerButton = !!value;
return (
<div className="flex flex-col gap-4">
<OrgBanner
data-testid="profile-upload-banner"
alt={`${defaultValues.name} Banner`}
className="grid aspect-3/1 w-full place-items-center rounded-lg"
fallback={
!value ? (
<div className="flex flex-col items-center gap-3 p-2">
<p className="text-sm text-muted-foreground">
{t("no_target", { target: "banner" })}
</p>
<div className="hidden">
<BannerUploader
height={500}
width={1500}
target="banner"
uploadInstruction={t("org_banner_instructions", {
height: 500,
width: 1500,
})}
id="banner-upload-inline"
buttonMsg={t("upload_banner")}
handleAvatarChange={handleBannerChange(onChange)}
imageSrc={value || undefined}
/>
</div>
<Button
variant="outline"
onClick={() => {
const triggerButton = document.querySelector(
'[data-testid="open-upload-banner-dialog"]'
) as HTMLButtonElement;
triggerButton?.click();
}}>
<UploadIcon aria-hidden="true" />
{t("upload_banner")}
</Button>
</div>
) : (
t("no_target", { target: "banner" })
)
}
imageSrc={value}
/>
{value && (
<div className="flex gap-2 items-center">
<BannerUploader
height={500}
width={1500}
target="banner"
uploadInstruction={t("org_banner_instructions", {
height: 500,
width: 1500,
})}
id="banner-upload"
buttonMsg={t("upload_banner")}
handleAvatarChange={handleBannerChange(onChange)}
imageSrc={value || undefined}
/>
{showRemoveBannerButton && (
<Button variant="ghost" size="sm" onClick={() => onChange(null)}>
{t("remove")}
</Button>
)}
</div>
)}
</div>
);
}}
/>API
Same trigger/dialog props as ImageUploader, plus:
| Prop | Type | Notes |
|---|---|---|
height | number | Output canvas height (e.g. 500) |
width | number | Output canvas width (e.g. 1500) |
Call sites
| File | Notes |
|---|---|
apps/web/modules/settings/my-account/components/profile-form-card.tsx | Profile avatar (ImageUploader) |
apps/web/modules/ee/teams/views/components/team-profile-form-card.tsx | Team logo |
apps/web/modules/ee/organizations/components/org-profile-form-card.tsx | Org logo + banner (BannerUploader) |
apps/web/modules/onboarding/personal/settings/personal-settings-view.tsx | Personal onboarding avatar |
apps/web/modules/onboarding/teams/details/team-details-view.tsx | Team onboarding logo |
apps/web/modules/onboarding/organization/brand/organization-brand-view.tsx | Org brand logo + banner |
Example URLs
Not exhaustive โ a few places you can open locally (e.g. http://localhost:3000) to see this in product.
| Path | Notes |
|---|---|
/settings/my-account/profile | Profile avatar uploader |
/settings/teams/<teamId>/profile | Team logo (needs a team) |
/settings/organizations/profile | Org logo + banner (org admin) |