# Avatar

> [llms.txt](https://zriz.dev/llms.txt)

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

The page at https\://zriz.dev/docs/avatar runs this cropper: a 3:2 photo under a round crop, a zoom slider with zoom out and zoom in, and open, reset and download in the corners.

## 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](https://zriz.dev/docs/loading-images.md) explains why. Safari fires no `change` for the same file twice, so `onClick` clears the input first.

```tsx
<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.

```tsx
<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](https://zriz.dev/docs/errors.md) lists every code.

```tsx
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.

```tsx
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`:

```tsx
"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×.
