Avatar Upload & Image Cropping
If you're building B2B SaaS in 2026 with user accounts, you need avatar upload — for profiles, comments, team members, customer records, brand logos. The naive approach: file input → upload original → display as-is. The structured approach: drag-and-drop upload, crop UI (square / circular preview), resize to multiple sizes, fallback letter-avatars when no image, EXIF strip, server-side processing, optimistic UI. Avatars look like a 30-minute feature; they're actually a 2-3 day feature done right. This guide covers the implementation craft. (See file-uploads-chat.md for general upload patterns; image-upload-processing-pipeline-chat.md for image-pipeline. This is avatar-specific.)
1. Decide avatar shape — circular vs square vs both
Decide avatar visual shape.
Circular (most common in 2026):
- Used by: Slack, Linear, GitHub (mostly), Notion
- CSS: border-radius: 50%
- Crop UI: circular mask preview
- Pro: friendly, modern
- Con: cuts off edges of rectangular images
Square / rounded-square:
- Used by: Discord, some enterprise tools
- CSS: border-radius: 8-12px (Discord's "squircle")
- Pro: shows more of image
- Con: less friendly
Both (rare):
- Some tools render same avatar as circle in some places, square in others
- Complicates crop UI (which is canonical?)
For B2B SaaS in 2026: default circular for users; consider square for company / brand logos.
Implementation:
- Crop UI matches display shape
- User crops once; you display in chosen shape
- Don't change shape after upload
Output:
1. Recommended shape
2. CSS implementation
3. Crop-UI consistency
4. Brand vs user differentiation
The "match crop to display" rule: if you display avatars circular, crop UI shows circular preview. Otherwise users crop a square they think is final, then see it cut into a circle.
2. Pick image handling library
Pick image handling stack for React in 2026.
Browser-side cropping:
react-image-crop:
- Most-popular React cropper
- Drag handles + aspect-ratio constraints
- ~25KB
- Recommended default
react-easy-crop:
- Alternative; simpler API
- Touch-friendly
- ~15KB
Pintura (commercial):
- Premium image editor
- Cropping + filters + adjustments
- $$$ but polished
Filerobot Image Editor (OSS):
- Full editor with cropping
- Larger bundle
Server-side processing:
Sharp (Node.js):
- Fast image processing (libvips)
- Resize, crop, format conversion
- Most-used in Node 2026
ImageMagick / GraphicsMagick:
- More features but slower
- Use Sharp unless specific need
Cloudinary / imgix / Vercel Image:
- Managed image CDN
- Resize on-the-fly via URL params
- No server-side code; offload to CDN
- Best for high-traffic apps
Combination (recommended):
- Client: react-image-crop for crop UI
- Server: Sharp for processing + storage
- Or: Cloudinary if budget allows
Output:
1. Library recommendations
2. Client + server stack
3. Bundle-size budget
4. Managed vs self-host decision
5. Sample integration
The 2026 stack for B2B SaaS: react-image-crop client + Sharp server + S3/R2/Blob storage + signed URLs. ~$0/mo if self-hosted; tested by thousands.
3. Upload UX — drag-drop + click
Implement avatar upload UX.
Triggers:
- Click avatar to upload (click on profile photo)
- Drag-and-drop file onto avatar area
- "Upload photo" button in settings
Drag-and-drop:
- Highlight on dragover ("Drop to upload")
- File-type filter (image/* only)
- File-size limit (5-10MB typical for avatars)
- Multi-file: single only (avatars are 1)
Click-to-upload:
- File input (hidden); style label
- accept="image/*"
Mobile:
- "Take photo" option (capture="user" attribute)
- "Choose from library"
- iOS / Android handle native picker
Loading states:
- Optimistic update (show new avatar before server confirms)
- Spinner overlay during upload
- Replace with final URL on success
- Revert + toast on failure
Validation:
- Client-side: file type, size
- Server-side: re-validate (don't trust client)
- Reject non-images with friendly error
Output:
1. Upload component (drag-drop + click)
2. Validation rules
3. Mobile considerations
4. Loading + error states
5. Optimistic update logic
The optimistic-update pattern: show new avatar immediately on file selection (using URL.createObjectURL). Revert if server fails. Feels instant.
4. Crop UI — preview + drag
Implement crop UI.
Flow:
1. User selects file
2. Modal opens with crop UI (image + crop overlay)
3. User drags crop area / resizes / rotates
4. "Save" → upload cropped result
5. "Cancel" → discard
Constraints:
- Aspect ratio locked (1:1 for avatars)
- Min crop size (don't allow tiny avatars)
- Max crop size (image dimensions)
Library: react-image-crop
import ReactCrop, { Crop } from 'react-image-crop';
import 'react-image-crop/dist/ReactCrop.css';
<ReactCrop
crop={crop}
onChange={(c) => setCrop(c)}
aspect={1}
circularCrop // for circular preview
minWidth={100}
>
<img ref={imgRef} src={src} />
</ReactCrop>
After crop: get final image
- Canvas-based: draw cropped region; export as blob
- Send blob to server
Mobile:
- Touch-drag for crop adjustment
- Pinch-zoom for image
- Full-screen crop modal
Rotation (optional):
- Rotate buttons (90° increments)
- Or: auto-rotate from EXIF
Zoom (optional):
- Slider or pinch-zoom
- Allows fine cropping of large images
Output:
1. Crop component implementation
2. Aspect-ratio constraints
3. Mobile touch UX
4. Canvas-export logic
5. Modal vs inline UX
The "always crop client-side first" rule: send cropped blob to server, not original 10MB image. Saves bandwidth + server work.
5. Server-side processing — Sharp pipeline
Implement server-side image processing.
Receive: cropped blob from client
Pipeline (Sharp):
import sharp from 'sharp';
async function processAvatar(buffer: Buffer): Promise<{
large: Buffer;
medium: Buffer;
small: Buffer;
}> {
const base = sharp(buffer)
.rotate() // auto-rotate from EXIF
.removeMetadata(); // strip EXIF
const large = await base.clone().resize(400, 400).webp({ quality: 85 }).toBuffer();
const medium = await base.clone().resize(200, 200).webp({ quality: 85 }).toBuffer();
const small = await base.clone().resize(80, 80).webp({ quality: 85 }).toBuffer();
return { large, medium, small };
}
Why multiple sizes:
- Different display contexts (profile page = large; comment = small)
- Don't load 400x400 for 32x32 display
- Image CDN can serve right size
Format: WebP
- Smaller than JPEG; supported in 2026 browsers
- AVIF for cutting edge (smaller; less universal)
- JPEG fallback for old browsers (rare in 2026)
EXIF stripping:
- Privacy: EXIF can include GPS, camera, timestamps
- Always strip for user-uploaded content
Validation:
- File type (verify magic bytes, not just extension)
- Size limits
- Image-bomb prevention (decompression bomb attacks)
- Max-pixel limits (prevent huge images)
Storage:
- S3 / R2 / Vercel Blob
- Key: avatars/{user_id}/{size}.webp
- Public-read OR signed URLs
CDN:
- CloudFront / Cloudflare / Vercel Image
- Cache aggressively (avatar URL changes on update)
- Or: Cloudinary / imgix for transform-on-the-fly
Output:
1. Sharp pipeline
2. Multi-size output
3. EXIF stripping
4. Validation
5. Storage + CDN
The image-bomb defense: malicious images can decompress to gigabytes of pixels and crash your server. Sharp's failOn: 'error' and pixel limit settings defend against this.
6. Letter / initials fallback
When no avatar uploaded, generate fallback.
Implement fallback avatars.
Letter avatar:
- First letter(s) of name on colored background
- Color hashed from name (consistent per user)
- Used by: Slack, Google, most B2B SaaS
Generation:
function getInitials(name: string): string {
const parts = name.split(' ');
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
function getColor(name: string): string {
const colors = [
'bg-red-500', 'bg-blue-500', 'bg-green-500',
'bg-yellow-500', 'bg-purple-500', 'bg-pink-500',
'bg-indigo-500', 'bg-teal-500'
];
const hash = name.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0);
return colors[hash % colors.length];
}
<div className={`${getColor(name)} text-white rounded-full w-10 h-10 flex items-center justify-center`}>
{getInitials(name)}
</div>
Implementation patterns:
Server-rendered (cache-friendly):
- Generate SVG / PNG server-side
- Cache by user_id
- Same URL pattern as uploaded avatars
Client-rendered (simpler):
- Render letter + color in React component
- No image asset needed
Hybrid:
- Letter fallback in React
- Replace with image when uploaded
Identicons (alternative):
- Generate geometric pattern from username
- Used by: GitHub legacy
- Library: jdenticon, identicon-svg
Color accessibility:
- Ensure 4.5:1 contrast (text vs background)
- Test in dark mode
- Avoid red/green for color-blindness
Output:
1. Initials extraction
2. Color hashing
3. Component implementation
4. Server vs client render
5. Accessibility check
The "consistent color per user" pattern: hash name → pick from palette. Same user always gets same color across the app. Subtle continuity.
7. Storage + URLs — public vs signed
Decide avatar storage + URL strategy.
Public URLs:
- avatars.example.com/{user_id}/large.webp
- Pro: simple; CDN-cacheable
- Con: anyone with URL can access
Signed URLs:
- Generate with expiration (1 hour typical)
- Pro: secure
- Con: complex; CDN cache busts
For avatars (recommended):
- Public URLs are fine
- User-uploaded but not sensitive
- Privacy: don't expose user_id in URL (use UUID slug)
Considerations:
- Avatar visible to org members + sometimes public (forums, public profiles)
- If strict privacy: signed URLs
- B2B internal: public OK
URL pattern:
- /avatars/{slug}/{size}.webp where slug = UUID derived from user_id
- Updates: change slug on update OR cache-bust with hash query param
Cache invalidation:
- Avatar URL changes when user updates
- Either: new file → new slug
- Or: same key → CDN purge on update
- Slug-rotation simpler
Multi-tenant:
- /orgs/{org_id}/avatars/... (org logos)
- /users/{user_id}/avatars/... (personal)
Output:
1. Storage path convention
2. URL pattern
3. Cache strategy
4. Multi-tenant scoping
5. Privacy considerations
The slug-rotation pattern: each upload generates new UUID. Old URL becomes 404; new one is fresh. Simpler than CDN purge.
8. Component composition — Avatar primitive
Avatars appear in 50+ places. Build a primitive.
Build reusable Avatar component.
Component API:
<Avatar
src={user.avatarUrl} // or undefined for fallback
name={user.name}
size="md" // sm / md / lg / xl
shape="circle" // circle / square
loading="lazy" // lazy by default
alt={user.name} // accessibility
/>
Sizes:
- xs: 16px (inline mentions)
- sm: 24px (small lists)
- md: 32px (comments, list rows)
- lg: 64px (profile cards)
- xl: 128px (profile page hero)
- 2xl: 256px (rare)
Composition:
- Wrap in <a> for clickable (link to profile)
- Tooltip on hover (name + role)
- Group avatars (avatar stack with overlap)
Group / stack:
<AvatarStack>
<Avatar ... />
<Avatar ... />
<Avatar ... />
<AvatarOverflow count={5} /> // "+5"
</AvatarStack>
Loading:
- Image loading state (skeleton or fallback letter)
- onError → show fallback
- preconnect to image CDN for faster load
Accessibility:
- alt text (user name)
- role="img" if no <img>
- Decorative avatars: alt="" + aria-hidden
shadcn/ui:
- Radix Avatar primitive provides Avatar.Root + Image + Fallback
- Recommended in 2026
Output:
1. Avatar component API
2. Size scale
3. AvatarStack composition
4. Loading + error states
5. Accessibility patterns
The Radix Avatar primitive: handles fallback automatically (shows fallback if image fails to load). Use it; don't reinvent.
9. Edge cases — corrupted, animated, oversized
Handle edge cases.
Corrupted images:
- Server validates before processing
- Sharp throws → return error to client
- Client shows error toast
- Fall back to letter avatar
Animated images (GIF):
- Take first frame for static avatar
- Or: convert to WebP animated (smaller)
- Or: reject animated (most B2B doesn't support)
Oversized images:
- File size: reject >10MB
- Pixel dimensions: reject >10K x 10K (image bomb)
- Server validates after client validates
EXIF orientation:
- iPhone photos rotate based on EXIF tag
- Without auto-rotate: appears sideways
- Sharp .rotate() reads EXIF and applies
Color profiles:
- Some images have non-sRGB color profiles
- Sharp normalizes; usually fine
- Test edge cases (Adobe RGB, DCI-P3)
PNGs with transparency:
- Convert to WebP (preserves alpha)
- Or composite over background color
SVG uploads:
- Don't allow user-uploaded SVG (XSS via embedded scripts)
- Or: heavy sanitization (DOMPurify)
- Most B2B SaaS: PNG/JPEG only
HEIC (iPhone):
- Native browser support varies
- Server-side convert: heic-convert npm
- Or: client-side heic2any
Memory:
- Large image processing crashes server
- Stream + size-limit
- Process in worker / queue for large
Output:
1. Edge-case test matrix
2. Validation rules (file + pixel + format)
3. EXIF handling
4. SVG / HEIC strategies
5. Memory protection
The HEIC trap: iPhone users upload HEIC files; many browsers can't display them. Server-side convert to JPEG/WebP, or client-side via heic2any.
10. Brand assets — different from avatars
For company logos / brand assets, requirements differ.
Handle brand assets separately.
Differences from user avatars:
Shape:
- Square or wide rectangle (not circle)
- Don't crop; preserve original
Format:
- PNG with transparent background (preferred)
- SVG (logos often vector)
- JPEG with white background (acceptable)
Sizes:
- Larger range needed (header logo to favicon)
- Generate: 16x16 favicon, 32x32, 64x64, 128x128, 256x256, full original
Crop UI:
- Optional / skip (most company logos pre-cropped)
- Allow re-positioning if rectangular
Storage:
- Same pattern as avatars but in /logos/{org_id}/
Display contexts:
- Header (small)
- Login page (medium)
- Email templates (medium)
- Public profile (large)
- Favicon (16x16)
Validation:
- Same image-bomb defenses
- SVG: require sanitization or reject
- Trademark: don't moderate (it's their logo)
Output:
1. Brand-asset upload flow
2. Multi-size generation
3. SVG handling decision
4. Display contexts
5. Favicon generation
The favicon-from-logo automation: generate 16x16 favicon from uploaded logo. Saves users a step. Test that small size remains recognizable.
What Done Looks Like
A v1 avatar system for B2B SaaS in 2026:
- Drag-drop + click upload
- Client-side crop UI (react-image-crop) with shape-matched preview
- Server-side processing (Sharp) with multi-size output
- WebP format; JPEG fallback
- EXIF stripped; image-bomb defended
- Storage: S3 / R2 / Blob with public URLs (or signed if sensitive)
- Letter fallback with consistent color hashing
- Reusable Avatar primitive (size + shape variants)
- AvatarStack for groups
- Accessibility: alt text + ARIA
- Mobile: native picker + take-photo
- Edge cases: HEIC, GIF, EXIF, oversized
Add later when product is mature:
- Image CDN (Cloudinary / imgix) for transform-on-the-fly
- Animated avatars (Discord-style)
- AI-generated avatars (DALL-E / Midjourney integration)
- Team avatar moderation
- Brand-asset variant (separate from user avatars)
The mistake to avoid: storing original full-size images and resizing on every request. Process once at upload; serve pre-sized files.
The second mistake: no fallback avatars. Empty grey circles look broken. Letter fallbacks fill the void.
The third mistake: uploading SVG without sanitization. SVG can contain JavaScript. Either sanitize or restrict to PNG/JPEG.
See Also
- File Uploads — general upload patterns
- Image Upload Processing Pipeline — broader image-pipeline
- File Preview & Document Viewer — file preview adjacent
- Settings & Account Management Pages — where avatars live
- Markdown Rendering & Sanitization — adjacent rendering
- Comments, Threading & @Mentions — avatars in comments
- Empty States, Loading & Error States — fallback patterns
- Performance Optimization — image perf
- VibeReference: Image CDN Providers — Cloudinary / imgix
- VibeReference: File Storage Providers — S3 / R2 / Vercel Blob
- VibeReference: Components — UI primitives
- VibeReference: Radix — Radix Avatar primitive
- VibeReference: shadcn/ui — Avatar component
- LaunchWeek: Brand Identity — brand asset standards