Skip to navigation

Avatar

A profile photo picker: the visitor picks a file, moves it under a circle and saves a round PNG.

Open
Download
1.0

Take a file from the visitor

src takes the File from the input as it is. Picking a new file turns save off until that photo loads.

Each pick bumps a counter that Root takes as its key, so every photo starts from the full frame. Loading images explains why. Safari fires no change for the same file twice, so onClick clears the input first.

<input
  aria-label="Choose a photo"
  type="file"
  accept="image/*"
  onClick={(event) => {
    event.currentTarget.value = "";
  }}
  onChange={(event) => {
    setStatus("empty");
    setSaveFailed(false);
    setPicks((count) => count + 1);
    setFile(event.target.files?.[0] ?? null);
  }}
/>

Crop to a circle

shape="circle" locks the aspect to 1 and draws a round mask. The area has no handles, so the visitor drags the photo under the circle and zooms it with a pinch or, with wheel="zoom", the mouse wheel.

minSize keeps the crop at least 512 by 512 source pixels. On a photo with a side under 512 px, the circle stays as large as the photo allows. onLoad fires when the photo has decoded and turns save on.

<Cropper.Root
  key={picks}
  actionsRef={actions}
  src={file}
  svg={false}
  shape="circle"
  minSize={{ width: 512, height: 512 }}
  onLoad={() => setStatus("ready")}
  onError={() => setStatus("failed")}
>
  <Cropper.Viewport
    wheel="zoom"
    style={{ width: 320, height: 320 }}
  >
    <Cropper.Image />
    <Cropper.Mask />
    <Cropper.Area aria-label="Crop area" />
  </Cropper.Viewport>
</Cropper.Root>

Say when a photo does not open

onError gets a ZrizError when the file is not a photo or the browser cannot decode it, such as a HEIC file in Chrome. The alert asks for another file. Errors lists every code.

const unreadable =
  "This photo does not open. Pick a JPEG, PNG or WebP.";

{status === "failed" && <p role="alert">{unreadable}</p>}

Save a PNG

exportCrop reads the circle from Root and returns a PNG with transparent corners, at most 512 by 512. It comes from @zriz/core/dom.

An export can still fail on a very large photo, so save shows its own alert when it does.

running holds the export in flight. A second click does nothing, and the button keeps focus because saving sets aria-disabled and leaves it enabled. A new pick aborts the export, so an old crop never lands on the next photo. If an export finishes between two clicks, onSave runs twice, so keep it safe to repeat.

const unsaved = "This crop did not save. Try again.";

const running = useRef<AbortController | null>(null);

useEffect(() => () => running.current?.abort(), [picks]);

const save = async () => {
  if (!actions.current || running.current) return;
  const controller = new AbortController();

  running.current = controller;
  setSaving(true);

  try {
    const { blob } = await exportCrop(actions.current, {
      maxSize: { width: 512, height: 512 },
      signal: controller.signal,
    });

    setSaveFailed(false);
    onSave(blob);
  } catch {
    setSaveFailed(!controller.signal.aborted);
  } finally {
    running.current = null;
    setSaving(false);
  }
};

{saveFailed && <p role="alert">{unsaved}</p>}

<button
  type="button"
  disabled={status !== "ready"}
  aria-disabled={saving}
  onClick={save}
>
  Save
</button>

The whole file

AvatarPicker hands onSave the PNG as a Blob:

"use client";

import { exportCrop } from "@zriz/core/dom";
import type { Cropper as CropperApi } from "@zriz/react";
import * as Cropper from "@zriz/react/cropper";
import "@zriz/react/styles.css";
import { useEffect, useRef, useState } from "react";

type Props = { onSave: (png: Blob) => void };
type Status = "empty" | "ready" | "failed";

const unreadable =
  "This photo does not open. Pick a JPEG, PNG or WebP.";
const unsaved = "This crop did not save. Try again.";

export const AvatarPicker = ({ onSave }: Props) => {
  const [file, setFile] = useState<File | null>(null);
  const [status, setStatus] = useState<Status>("empty");
  const [saveFailed, setSaveFailed] = useState(false);
  const [picks, setPicks] = useState(0);
  const [saving, setSaving] = useState(false);
  const actions = useRef<CropperApi>(null);
  const running = useRef<AbortController | null>(null);

  useEffect(() => () => running.current?.abort(), [picks]);

  const save = async () => {
    if (!actions.current || running.current) return;
    const controller = new AbortController();

    running.current = controller;
    setSaving(true);

    try {
      const { blob } = await exportCrop(actions.current, {
        maxSize: { width: 512, height: 512 },
        signal: controller.signal,
      });

      setSaveFailed(false);
      onSave(blob);
    } catch {
      setSaveFailed(!controller.signal.aborted);
    } finally {
      running.current = null;
      setSaving(false);
    }
  };

  return (
    <>
      <input
        aria-label="Choose a photo"
        type="file"
        accept="image/*"
        onClick={(event) => {
          event.currentTarget.value = "";
        }}
        onChange={(event) => {
          setStatus("empty");
          setSaveFailed(false);
          setPicks((count) => count + 1);
          setFile(event.target.files?.[0] ?? null);
        }}
      />
      <Cropper.Root
        key={picks}
        actionsRef={actions}
        src={file}
        svg={false}
        shape="circle"
        minSize={{ width: 512, height: 512 }}
        onLoad={() => setStatus("ready")}
        onError={() => setStatus("failed")}
      >
        <Cropper.Viewport
          wheel="zoom"
          style={{ width: 320, height: 320 }}
        >
          <Cropper.Image />
          <Cropper.Mask />
          <Cropper.Area aria-label="Crop area" />
        </Cropper.Viewport>
      </Cropper.Root>
      {status === "failed" && <p role="alert">{unreadable}</p>}
      {saveFailed && <p role="alert">{unsaved}</p>}
      <button
        type="button"
        disabled={status !== "ready"}
        aria-disabled={saving}
        onClick={save}
      >
        Save
      </button>
    </>
  );
};

The demo above runs this file with the styles from the zriz site. Its open, reset and download buttons take the place of the input, the alerts and save. It adds a zoom slider with step buttons, and maxZoom on Root stops the zoom at 3×.