VibeWeek
Home/Grow/Avatar Upload & Image Cropping

Avatar Upload & Image Cropping

⬅️ Day 6: Grow Overview

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