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 (
<LogoUpload
handler={handler}
value={logoUrl}
onChange={setLogoUrl}
aspect="wide" // 16:9 aspect ratio
placeholder="Upload company logo"
/>
)
}

Props

PropTypeDescription
handlerUploadHandlerUpload handler (required)
valuestring | nullCurrent logo URL
onChange(url: string | null) => voidCalled when logo changes
aspect"square" | "wide" | "banner" | "auto"Aspect ratio (default: "wide")
size"sm" | "default" | "lg" | "xl"Height size (default: "default")
placeholderstringPlaceholder text (default: "Upload logo")
objectFit"contain" | "cover" | "fill"Image fit mode (default: "contain")
acceptstring[]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>
<LogoUpload
handler={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>
<LogoUpload
handler={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)}>
<div
role="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"
)}
>
<input
ref={inputRef}
type="file"
accept={accept.join(",")}
onChange={handleFileSelect}
className="hidden"
disabled={disabled || isLoading}
/>
{displayUrl ? (
<>
<img
src={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 && (
<button
type="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>
);
}