Logo Upload
A rectangular logo upload component with multiple aspect ratio options, SVG support, and customizable placeholder.
Demo
Wide (16:9)
Upload your logo
Square (1:1)
Upload logo
Wide (16:9)
Upload logo
Banner (3:1)
Upload logo
Basic Usage
company-logo.tsx
import { LogoUpload, createStack0Handler } from '@stack0/elements'const handler = createStack0Handler({apiKey: process.env.NEXT_PUBLIC_STACK0_API_KEY!,folder: 'logos',})export function CompanyLogo() {const [logoUrl, setLogoUrl] = useState<string | null>(null)return (<LogoUploadhandler={handler}value={logoUrl}onChange={setLogoUrl}aspect="wide" // 16:9 aspect ratioplaceholder="Upload company logo"/>)}
Props
| Prop | Type | Description |
|---|---|---|
| handler | UploadHandler | Upload handler (required) |
| value | string | null | Current logo URL |
| onChange | (url: string | null) => void | Called when logo changes |
| aspect | "square" | "wide" | "banner" | "auto" | Aspect ratio (default: "wide") |
| size | "sm" | "default" | "lg" | "xl" | Height size (default: "default") |
| placeholder | string | Placeholder text (default: "Upload logo") |
| objectFit | "contain" | "cover" | "fill" | Image fit mode (default: "contain") |
| accept | string[] | Accepted MIME types (includes SVG by default) |
Aspect Ratio Variants
aspect-ratios.tsx
// Square (1:1)<LogoUpload handler={handler} aspect="square" />// Wide (16:9) - Default<LogoUpload handler={handler} aspect="wide" />// Banner (3:1) - Great for headers<LogoUpload handler={handler} aspect="banner" />// Auto - No fixed aspect ratio<LogoUpload handler={handler} aspect="auto" />
Dark/Light Logo Variants
brand-settings.tsx
function BrandingSettings() {const [lightLogo, setLightLogo] = useState<string | null>(null)const [darkLogo, setDarkLogo] = useState<string | null>(null)return (<div className="space-y-6"><div><label className="text-sm font-medium">Light Mode Logo</label><p className="text-xs text-muted-foreground mb-2">Used on light backgrounds</p><LogoUploadhandler={handler}value={lightLogo}onChange={setLightLogo}aspect="wide"placeholder="Upload light logo"/></div><div><label className="text-sm font-medium">Dark Mode Logo</label><p className="text-xs text-muted-foreground mb-2">Used on dark backgrounds</p><LogoUploadhandler={handler}value={darkLogo}onChange={setDarkLogo}aspect="wide"placeholder="Upload dark logo"/></div></div>)}
Full Component Source
Copy this into your project at components/ui/logo-upload.tsx:
components/ui/logo-upload.tsx
"use client";import { cva, type VariantProps } from "class-variance-authority";import { ImageIcon, Loader2, Upload, X } from "lucide-react";import * as React from "react";import { cn } from "@/lib/utils";interface UploadHandler {getUploadUrl: (file: File) => Promise<{ uploadUrl: string; assetId?: string }>;onUploadComplete?: (assetId: string, file: File) => Promise<{ url?: string }>;onError?: (error: Error, file: File) => void;}const containerVariants = cva("relative rounded-lg overflow-hidden bg-muted border-2 border-dashed border-muted-foreground/25 flex items-center justify-center cursor-pointer transition-all group hover:border-muted-foreground/50",{variants: {size: { sm: "h-16", default: "h-24", lg: "h-32", xl: "h-40" },aspect: {square: "aspect-square",wide: "aspect-video",banner: "aspect-[3/1]",auto: "",},},defaultVariants: { size: "default", aspect: "wide" },});interface LogoUploadProps extends VariantProps<typeof containerVariants> {handler: UploadHandler;value?: string | null;onChange?: (url: string | null) => void;onUploadComplete?: (url: string) => void;maxSize?: number;accept?: string[];allowRemove?: boolean;disabled?: boolean;placeholder?: string;objectFit?: "contain" | "cover" | "fill";className?: string;}type UploadState = "idle" | "uploading" | "processing" | "error";function formatBytes(bytes: number): string {if (bytes === 0) return "0 B";const k = 1024;const sizes = ["B", "KB", "MB", "GB"];const i = Math.floor(Math.log(bytes) / Math.log(k));return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;}export function LogoUpload({handler,value,onChange,onUploadComplete,maxSize = 5 * 1024 * 1024,accept = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"],allowRemove = true,disabled = false,placeholder = "Upload logo",objectFit = "contain",size,aspect,className,}: LogoUploadProps) {const [uploadState, setUploadState] = React.useState<UploadState>("idle");const [error, setError] = React.useState<string | null>(null);const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);const inputRef = React.useRef<HTMLInputElement>(null);React.useEffect(() => {return () => { previewUrl && URL.revokeObjectURL(previewUrl); };}, [previewUrl]);const handleClick = () => !disabled && uploadState === "idle" && inputRef.current?.click();const handleRemove = (e: React.MouseEvent) => {e.stopPropagation();previewUrl && URL.revokeObjectURL(previewUrl);setPreviewUrl(null);onChange?.(null);};const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {const file = e.target.files?.[0];e.target.value = "";if (!file) return;const isValidType = file.type.startsWith("image/") || accept.includes(file.type);if (!isValidType) {setError("Please select a valid image file");return;}if (file.size > maxSize) {setError(`Image must be less than ${formatBytes(maxSize)}`);return;}setError(null);const objectUrl = URL.createObjectURL(file);previewUrl && URL.revokeObjectURL(previewUrl);setPreviewUrl(objectUrl);try {setUploadState("uploading");const { uploadUrl, assetId } = await handler.getUploadUrl(file);const response = await fetch(uploadUrl, {method: "PUT",body: file,headers: { "Content-Type": file.type },});if (!response.ok) throw new Error("Upload failed");setUploadState("processing");let finalUrl = objectUrl;if (handler.onUploadComplete && assetId) {const result = await handler.onUploadComplete(assetId, file);if (result.url) {finalUrl = result.url;URL.revokeObjectURL(objectUrl);setPreviewUrl(null);}}setUploadState("idle");onChange?.(finalUrl);onUploadComplete?.(finalUrl);} catch (err) {setUploadState("error");setError(err instanceof Error ? err.message : "Upload failed");handler.onError?.(err instanceof Error ? err : new Error("Upload failed"), file);}};const displayUrl = previewUrl || value;const isLoading = uploadState === "uploading" || uploadState === "processing";return (<div className={cn("w-full", className)}><divrole="button"tabIndex={disabled ? -1 : 0}onClick={handleClick}onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && handleClick()}className={cn(containerVariants({ size, aspect }),displayUrl && "border-solid border-muted-foreground/15",disabled && "opacity-50 cursor-not-allowed","focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2")}><inputref={inputRef}type="file"accept={accept.join(",")}onChange={handleFileSelect}className="hidden"disabled={disabled || isLoading}/>{displayUrl ? (<><imgsrc={displayUrl}alt="Logo"className={cn("h-full w-full p-2",objectFit === "contain" && "object-contain",objectFit === "cover" && "object-cover",objectFit === "fill" && "object-fill")}/>{!disabled && !isLoading && (<div className="absolute inset-0 bg-black/60 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"><Upload className="h-6 w-6 text-white" /></div>)}</>) : (<div className="flex flex-col items-center justify-center gap-2 p-4"><ImageIcon className="h-8 w-8 text-muted-foreground" /><span className="text-sm text-muted-foreground">{placeholder}</span></div>)}{isLoading && (<div className="absolute inset-0 bg-black/60 flex items-center justify-center"><Loader2 className="h-6 w-6 text-white animate-spin" /></div>)}{allowRemove && displayUrl && !disabled && !isLoading && (<buttontype="button"onClick={handleRemove}className="absolute top-2 right-2 h-6 w-6 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90 opacity-0 group-hover:opacity-100"><X className="h-3 w-3" /></button>)}</div>{error && <p className="text-xs text-destructive mt-2">{error}</p>}{isLoading && (<p className="text-xs text-muted-foreground mt-2">{uploadState === "uploading" ? "Uploading..." : "Processing..."}</p>)}</div>);}