Media

Avatar upload

Picks a profile picture with a round preview, replace, remove, drag and drop and a message for refused files.

Built on
<input type="file">Drag and dropURL.createObjectURL
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
3.4 kB
Built from
ButtonIcon
AO
Profile picturePNG or JPG, up to 2 MB
Code
"use client";

import { AvatarUpload } from "@/components/equip/avatar-upload";

export default function Demo() {
  return <AvatarUpload name="Ana Ortiz" hint="PNG or JPG, up to 2 MB" maxSize={2_000_000} size={64} />;
}

Examples

Controlled, with a save step

With src you own the picture: make a preview URL from the file, upload it when the form is saved, and pass error if that fails.

Kwame Mensah, current picture
Profile pictureSquare images look best
Code
"use client";

import { useEffect, useState } from "react";
import { Button } from "@/components/equip/button";
import { AvatarUpload } from "@/components/equip/avatar-upload";

const current = `data:image/svg+xml,${encodeURIComponent(
  '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 20 20"><rect width="20" height="20" fill="#c9d8c5"/><circle cx="10" cy="8" r="4" fill="#8a5a3c"/><path d="M3 20 Q3 13 10 13 Q17 13 17 20Z" fill="#2f5d50"/></svg>',
)}`;

export default function Example() {
  const [src, setSrc] = useState<string | null>(current);
  const [file, setFile] = useState<File | null | undefined>(undefined);
  const [state, setState] = useState<"idle" | "saved" | "failed">("idle");

  // Free the preview made for a chosen file when it is replaced.
  useEffect(() => () => void (src?.startsWith("blob:") && URL.revokeObjectURL(src)), [src]);

  return (
    <form
      style={{ display: "grid", gap: 12, justifyItems: "start" }}
      onSubmit={(event) => {
        event.preventDefault();
        // No server here: a picture over 1 MB stands in for a failed upload.
        setState(file && file.size > 1_000_000 ? "failed" : "saved");
      }}
    >
      <AvatarUpload
        name="Kwame Mensah"
        src={src}
        hint="Square images look best"
        error={state === "failed" ? "Upload failed. Try a smaller image." : undefined}
        onFileChange={(next) => {
          setFile(next);
          setState("idle");
          setSrc(next ? URL.createObjectURL(next) : null);
        }}
      />
      <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
        <Button type="submit" variant="accent" disabled={file === undefined}>
          Save profile
        </Button>
        <span role="status" style={{ fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>
          {state === "saved" ? (file ? "Picture saved." : "Picture removed.") : ""}
        </span>
      </div>
    </form>
  );
}

Sizes and states

Without a picture the circle shows initials. A dropped file goes through the same checks as a picked one.

Larder, current picture
Team logoSVG, PNG or JPG
LF
Profile pictureUp to 500 KB
AM
Profile pictureArchived accounts cannot change their picture
Code
"use client";

import { AvatarUpload } from "@/components/equip/avatar-upload";

const team = `data:image/svg+xml,${encodeURIComponent(
  '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 20 20"><rect width="20" height="20" fill="#f3c98b"/><path d="M4 15 L10 4 L16 15Z" fill="#b5523b"/></svg>',
)}`;

export default function Example() {
  return (
    <div style={{ display: "grid", gap: 20 }}>
      <AvatarUpload name="Larder" label="Team logo" defaultSrc={team} size={96} hint="SVG, PNG or JPG" accept="image/svg+xml,image/png,image/jpeg" />
      <AvatarUpload name="Lena Fischer" size={48} maxSize={500_000} hint="Up to 500 KB" />
      <AvatarUpload name="Archived member" size={48} disabled hint="Archived accounts cannot change their picture" />
    </div>
  );
}

Props

PropTypeDefaultWhat it does
namestringnoneWhose picture it is. Used for the initials and the image's alt text.
srcstring | nullnoneThe picture to show, when you control it. null shows the initials.
defaultSrcstringnoneThe picture shown first, when you do not.
onFileChange(file: File | null) => voidnoneCalled with an accepted file, or null on remove. The part never uploads.
labelReactNode"Profile picture"Visible label.
hintReactNodenoneQuiet line under the label, such as accepted types and size.
errorReactNodenoneYour message, such as a failed upload. Shown in place of the part's own checks.
acceptstring"image/png,image/jpeg,image/webp,image/gif"Same format as the native attribute. Dropped files are checked too.
maxSizenumbernoneLargest accepted file, in bytes.
sizenumber72Circle diameter in pixels.
inputNamestringnoneName of the file input, so a plain form posts the chosen file.
disabledbooleanfalseStops picking, dropping and removing.
...restComponentProps<"div">noneEvery native div attribute.

Anatomy

root.eq-avatar-uploaddata-dragging while a file is held over the circle.
circle.eq-avatar-upload-circleThe preview and drop target. A click opens the file picker.
image.eq-avatar-upload-image
initials.eq-avatar-upload-initialsShown when there is no picture.
overlay.eq-avatar-upload-overlayCamera icon on hover and while dragging.
body.eq-avatar-upload-body
label.eq-avatar-upload-label
hint.eq-avatar-upload-hint
actions.eq-avatar-upload-actionsUpload or replace, and remove.
message.eq-avatar-upload-messageA status region for refusals and your error.
input.eq-avatar-upload-inputThe native file input, hidden; the buttons open it.

Keyboard

EnterSpaceOn Upload photo or Replace, opens the file picker. On Remove, removes the picture and focuses the upload button.

Notes

  • The buttons are the keyboard and screen reader path; the circle repeats the picker and takes drops for pointer users.
  • Refusals, such as a wrong type or a file over maxSize, are written in a status region and the input is cleared.
  • The part does not upload or crop. Pass the file to your own upload, or to the image cropper first, and set src when it is stored.
  • Uncontrolled, it makes and frees its own preview URL; controlled, you make the URL and should revoke it when it changes.
  • The accept check reads the file's type and name, which the browser reports; check the contents again on the server.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/avatar-upload.tsx208 lines
"use client";

import { useEffect, useId, useRef, useState, type ComponentProps, type CSSProperties, type DragEvent, type ReactNode } from "react";
import { Button } from "./button";
import { Icon } from "./icon";
import "./avatar-upload.css";

export type AvatarUploadProps = Omit<ComponentProps<"div">, "onChange" | "defaultValue"> & {
  /** Whose picture it is. Used for the initials and the image's alt text. */
  name: string;
  /** The picture to show, when you control it. null shows the initials. */
  src?: string | null;
  /** The picture to show first, when you do not. */
  defaultSrc?: string;
  /** Called with an accepted file, or null when the picture is removed. The part never uploads. */
  onFileChange?: (file: File | null) => void;
  /** Visible label. */
  label?: ReactNode;
  /** Quiet line under the label, such as accepted types and size. */
  hint?: ReactNode;
  /** A message from you, such as a failed upload. Shown in place of the part's own checks. */
  error?: ReactNode;
  /** Same format as the native attribute. Dropped files are checked against it too. */
  accept?: string;
  /** Largest accepted file, in bytes. */
  maxSize?: number;
  /** Circle diameter in pixels. */
  size?: number;
  /** Name of the file input, so a form posts the chosen file. */
  inputName?: string;
  disabled?: boolean;
};

/** "Ana MarĂ­a Ortiz" gives "AO", "Ravi" gives "R". */
function initials(name: string): string {
  const words = name.trim().split(/\s+/).filter(Boolean);
  const first = words[0];
  if (first === undefined) return "";
  const last = words.length > 1 ? words[words.length - 1] : undefined;
  return ((Array.from(first)[0] ?? "") + (last === undefined ? "" : (Array.from(last)[0] ?? ""))).toLocaleUpperCase();
}

function formatSize(bytes: number): string {
  if (bytes < 1000) return `${bytes} B`;
  if (bytes < 1_000_000) return `${Math.round(bytes / 1000)} KB`;
  const mb = bytes / 1_000_000;
  return `${mb < 10 ? mb.toFixed(1).replace(/\.0$/, "") : Math.round(mb)} MB`;
}

/** The same rules as the accept attribute: extensions, exact types and type/* wildcards. */
function matches(file: File, accept: string): boolean {
  const fileName = file.name.toLowerCase();
  const type = file.type.toLowerCase();
  return accept
    .split(",")
    .map((rule) => rule.trim().toLowerCase())
    .filter(Boolean)
    .some((rule) => (rule.startsWith(".") ? fileName.endsWith(rule) : rule.endsWith("/*") ? type.startsWith(rule.slice(0, -1)) : type === rule));
}

export function AvatarUpload({
  name,
  src,
  defaultSrc,
  onFileChange,
  label = "Profile picture",
  hint,
  error,
  accept = "image/png,image/jpeg,image/webp,image/gif",
  maxSize,
  size = 72,
  inputName,
  disabled = false,
  className,
  style,
  ...rest
}: AvatarUploadProps) {
  const id = useId();
  const input = useRef<HTMLInputElement>(null);
  const chooser = useRef<HTMLButtonElement>(null);
  const [local, setLocal] = useState<string | null | undefined>(undefined);
  const [problem, setProblem] = useState("");
  const [dragging, setDragging] = useState(false);
  const depth = useRef(0);

  // A preview made here is ours to free.
  useEffect(() => () => void (local && URL.revokeObjectURL(local)), [local]);

  const picture = src !== undefined ? src : local !== undefined ? local : (defaultSrc ?? null);
  const message = error ?? (problem || null);

  function take(file: File | undefined) {
    if (!file || disabled) return;
    if (!matches(file, accept)) {
      setProblem(`${file.name} is not an image this field takes.`);
    } else if (maxSize !== undefined && file.size > maxSize) {
      setProblem(`${file.name} is ${formatSize(file.size)}. The limit is ${formatSize(maxSize)}.`);
    } else {
      setProblem("");
      if (src === undefined) setLocal(URL.createObjectURL(file));
      onFileChange?.(file);
      return;
    }
    if (input.current) input.current.value = "";
  }

  function remove() {
    setProblem("");
    if (input.current) input.current.value = "";
    if (src === undefined) setLocal(null);
    onFileChange?.(null);
    // Remove leaves with the picture, so focus goes to the button that adds one.
    chooser.current?.focus();
  }

  function onDragEnter(event: DragEvent<HTMLDivElement>) {
    if (disabled || !event.dataTransfer.types.includes("Files")) return;
    event.preventDefault();
    depth.current += 1;
    setDragging(true);
  }

  function onDrop(event: DragEvent<HTMLDivElement>) {
    depth.current = 0;
    setDragging(false);
    if (disabled) return;
    event.preventDefault();
    take(event.dataTransfer.files[0]);
  }

  return (
    <div
      {...rest}
      className={["eq-avatar-upload", className].filter(Boolean).join(" ")}
      style={{ ...style, "--_size": `${size}px` } as CSSProperties}
      data-dragging={dragging || undefined}
      data-disabled={disabled || undefined}
    >
      <div
        className="eq-avatar-upload-circle"
        onClick={() => !disabled && input.current?.click()}
        onDragEnter={onDragEnter}
        onDragOver={(event) => {
          if (disabled || !event.dataTransfer.types.includes("Files")) return;
          event.preventDefault();
          event.dataTransfer.dropEffect = "copy";
        }}
        onDragLeave={() => {
          depth.current = Math.max(0, depth.current - 1);
          if (depth.current === 0) setDragging(false);
        }}
        onDrop={onDrop}
      >
        {picture ? (
          <img className="eq-avatar-upload-image" src={picture} alt={`${name}, current picture`} />
        ) : (
          <span className="eq-avatar-upload-initials" role="img" aria-label={`${name}, no picture`}>
            {initials(name) || <Icon name="user" size={24} />}
          </span>
        )}
        <span className="eq-avatar-upload-overlay" aria-hidden="true">
          <Icon name="camera" size={20} />
        </span>
      </div>
      <div className="eq-avatar-upload-body">
        <span className="eq-avatar-upload-label" id={`${id}-label`}>
          {label}
        </span>
        {hint != null && (
          <span className="eq-avatar-upload-hint" id={`${id}-hint`}>
            {hint}
          </span>
        )}
        <div className="eq-avatar-upload-actions">
          <Button
            ref={chooser}
            size="s"
            variant="outline"
            disabled={disabled}
            aria-describedby={[`${id}-label`, hint != null ? `${id}-hint` : "", message ? `${id}-message` : ""].filter(Boolean).join(" ")}
            onClick={() => input.current?.click()}
          >
            {picture ? "Replace" : "Upload photo"}
          </Button>
          {picture && (
            <Button size="s" variant="ghost" disabled={disabled} onClick={remove}>
              Remove
            </Button>
          )}
        </div>
        <span className="eq-avatar-upload-message" id={`${id}-message`} role="status">
          {message}
        </span>
      </div>
      <input
        ref={input}
        className="eq-avatar-upload-input"
        type="file"
        name={inputName}
        accept={accept}
        disabled={disabled}
        tabIndex={-1}
        aria-hidden="true"
        onChange={(event) => take(event.currentTarget.files?.[0])}
      />
    </div>
  );
}
components/equip/avatar-upload.css127 lines
@layer equip {
  .eq-avatar-upload {
    display: flex;
    align-items: center;
    gap: var(--eq-space-3);
    min-width: 0;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1.35 var(--eq-font);
  }

  .eq-avatar-upload-circle {
    position: relative;
    box-sizing: border-box;
    display: grid;
    flex: none;
    place-items: center;
    width: var(--_size);
    height: var(--_size);
    overflow: hidden;
    border: 1px solid var(--eq-line);
    border-radius: var(--eq-radius-full);
    background: color-mix(in srgb, var(--eq-ink) 9%, var(--eq-raised));
    color: var(--eq-ink-2);
    cursor: pointer;
    transition:
      border-color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur-move) var(--eq-spring);
  }

  /* A file held over the circle lifts it: dashed ink edge. */
  .eq-avatar-upload[data-dragging] .eq-avatar-upload-circle {
    border: 2px dashed var(--eq-ink);
    scale: 1.06;
  }

  .eq-avatar-upload[data-disabled] {
    opacity: 0.45;
  }

  .eq-avatar-upload[data-disabled] .eq-avatar-upload-circle {
    cursor: not-allowed;
  }

  .eq-avatar-upload-image {
    width: 100%;
    height: 100%;
    object-fit: cover;
    /* Arrives rather than appears. */
    animation: eq-avatar-upload-in var(--eq-dur-move) var(--eq-spring);
  }

  @keyframes eq-avatar-upload-in {
    from {
      opacity: 0;
      scale: 0.9;
    }
  }

  .eq-avatar-upload-initials {
    font-weight: var(--eq-weight-strong);
    font-size: calc(var(--_size) * 0.34);
    letter-spacing: 0.02em;
    user-select: none;
  }

  .eq-avatar-upload-overlay {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    background: color-mix(in srgb, var(--eq-solid) 55%, transparent);
    color: var(--eq-solid-ink);
    opacity: 0;
    transition: opacity var(--eq-dur) var(--eq-ease);
  }

  @media (hover: hover) {
    .eq-avatar-upload:not([data-disabled]) .eq-avatar-upload-circle:hover .eq-avatar-upload-overlay {
      opacity: 1;
    }
  }

  .eq-avatar-upload[data-dragging] .eq-avatar-upload-overlay {
    opacity: 1;
  }

  .eq-avatar-upload-body {
    display: grid;
    gap: 2px;
    justify-items: start;
    min-width: 0;
  }

  .eq-avatar-upload-label {
    font-weight: var(--eq-weight-strong);
  }

  .eq-avatar-upload-hint {
    color: var(--eq-ink-2);
  }

  .eq-avatar-upload-actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--eq-space-1);
    margin-top: var(--eq-space-1);
  }

  .eq-avatar-upload-message {
    color: var(--eq-danger);
  }

  .eq-avatar-upload-message:empty {
    display: none;
  }

  .eq-avatar-upload-input {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: 0;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    opacity: 0;
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index