All patterns

Avatar Upload Crop

A picked image drops into the circular frame and settles oversized, ready to be nudged.

authenticationfriendlyelegantinteraction · finite · intermediate · ~0.7s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

429 lines · react + motion only
import { useState, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Avatar Upload Crop
 *
 * The moment a picked image becomes a profile picture. The image drops
 * into the circular frame and settles slightly oversized, which is what
 * makes the crop feel adjustable rather than final — then the zoom
 * controls nudge it before it is saved.
 *
 * The picked image is stood in for by a flat two-tone placeholder tile:
 * the component ships with no asset and shows no photograph of a person.
 * Avatars elsewhere in the flow are initials on a coloured disc. Pass
 * `imageSrc` to crop a real picture — the demo uses a pet portrait, so
 * no person appears anywhere in the flow.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Neutrals mix from the inherited text color, so the panel reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `initials`, `accent`,
 * `imageSrc`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type AvatarUploadCropProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Initials shown before an image is picked — never a photo. */
  initials?: string;
  /** The picked picture being cropped; omit for the flat stand-in. */
  imageSrc?: string;
  /** Name under the frame. */
  personName?: string;
  /** Accent for controls and the saved confirmation. */
  accent?: string;
  /** Fires when the crop is saved. */
  onSave?: () => void;
};

type VariantConfig = {
  /** How far above the frame the image starts, in px. */
  drop: number;
  /** How oversized it lands before the user adjusts it. */
  settleScale: number;
  /** Step taken by each zoom press. */
  step: number;
  image: { type: "spring"; stiffness: number; damping: number };
  zoom: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the only thing that scales here is the image inside the
// crop — it holds no text, which is exactly why it is allowed to. Both
// springs sit above a 0.8 damping ratio, so the image never rubber-bands
// against the edge of the mask.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost a placement. For settings screens where changing a picture is
  // maintenance.
  subtle: {
    drop: 12,
    settleScale: 1.06,
    step: 0.12,
    image: { type: "spring", stiffness: 520, damping: 42 },
    zoom: { type: "spring", stiffness: 560, damping: 44 },
  },
  // The all-purpose setting: the image visibly arrives in the frame.
  default: {
    drop: 22,
    settleScale: 1.12,
    step: 0.16,
    image: { type: "spring", stiffness: 400, damping: 36 },
    zoom: { type: "spring", stiffness: 440, damping: 38 },
  },
  // A longer drop for a first-run step where this is the whole screen.
  playful: {
    drop: 32,
    settleScale: 1.2,
    step: 0.2,
    image: { type: "spring", stiffness: 320, damping: 31 },
    zoom: { type: "spring", stiffness: 360, damping: 33 },
  },
};

/** Theme-adaptive neutral: `currentColor` is the inherited text color, so
 *  mixing it with `transparent` yields a surface, border or fill that is
 *  correctly toned on a light page and on a dark one. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const FRAME = 112;

export default function AvatarUploadCrop({
  variant = "default",
  initials = "PR",
  personName = "Priya Raman",
  accent = "#5B5BD6",
  imageSrc,
  onSave,
}: AvatarUploadCropProps) {
  const [picked, setPicked] = useState(false);
  const [saved, setSaved] = useState(false);
  const [zoom, setZoom] = useState(0);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const scale = cfg.settleScale + zoom * cfg.step;

  const nudge = (direction: number) =>
    setZoom((current) => Math.min(2, Math.max(-1, current + direction)));

  const save = () => {
    setSaved(true);
    onSave?.();
  };

  return (
    <div
      style={{
        width: 320,
        padding: 18,
        borderRadius: 16,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
      }}
    >
      <div style={{ fontSize: 14, fontWeight: 650 }}>Add a profile photo</div>
      <div style={{ fontSize: 12, opacity: 0.55, marginTop: 4, lineHeight: 1.5 }}>
        Teammates recognise you faster with one
      </div>

      <div style={{ display: "flex", alignItems: "center", gap: 16, marginTop: 16 }}>
        <div style={{ position: "relative", width: FRAME, height: FRAME, flexShrink: 0 }}>
          <div
            style={{
              position: "absolute",
              inset: 0,
              borderRadius: 999,
              overflow: "hidden",
              background: tone(10),
              display: "grid",
              placeItems: "center",
            }}
          >
            {/* Before a file is picked, the avatar is initials on a disc —
                no image, no asset, nothing to fail to load. */}
            <motion.span
              aria-hidden
              animate={{ opacity: picked ? 0 : 1 }}
              transition={{ duration: 0.18, ease: "easeOut" }}
              style={{ fontSize: 30, fontWeight: 700, letterSpacing: 0.5, opacity: 0.75 }}
            >
              {initials}
            </motion.span>

            <AnimatePresence>
              {picked && (
                <motion.div
                  key="image"
                  aria-hidden
                  initial={
                    reduceMotion
                      ? { opacity: 0, scale: cfg.settleScale }
                      : { opacity: 0, y: -cfg.drop, scale: cfg.settleScale + 0.1 }
                  }
                  animate={{ opacity: 1, y: 0, scale }}
                  exit={{ opacity: 0 }}
                  transition={
                    reduceMotion
                      ? { duration: 0.16, ease: "easeOut" }
                      : {
                          y: cfg.image,
                          scale: cfg.zoom,
                          opacity: { duration: 0.18, ease: "easeOut" },
                        }
                  }
                  style={{
                    position: "absolute",
                    inset: 0,
                    // A flat placeholder standing in for the picked file.
                    // Literal colors: it represents an image, not a
                    // surface, so it does not follow the page theme.
                    background: imageSrc
                      ? "#1A1D28"
                      : "linear-gradient(150deg, #6E77E8 0%, #7FB4E8 46%, #9BD6C4 100%)",
                    display: "grid",
                    placeItems: "center",
                  }}
                >
                  {imageSrc ? (
                    <img
                      src={imageSrc}
                      alt=""
                      draggable={false}
                      style={{
                        position: "absolute",
                        inset: 0,
                        width: "100%",
                        height: "100%",
                        objectFit: "cover",
                        display: "block",
                      }}
                    />
                  ) : (
                    <svg width="46" height="46" viewBox="0 0 40 40" fill="none" aria-hidden>
                      <circle cx="26.5" cy="13" r="3.6" fill="rgba(255,255,255,0.55)" />
                      <path
                        d="M4 30.5l8.5-9.5 6 6.5 5.5-5 12 8v6H4v-6z"
                        fill="rgba(255,255,255,0.32)"
                      />
                    </svg>
                  )}
                </motion.div>
              )}
            </AnimatePresence>
          </div>

          {/* Crop guide sits above the mask, so it stays put while the
              image moves underneath it. */}
          <div
            aria-hidden
            style={{
              position: "absolute",
              inset: 0,
              borderRadius: 999,
              border: `1.5px dashed ${tone(26)}`,
              pointerEvents: "none",
            }}
          />

          <svg
            width={FRAME}
            height={FRAME}
            viewBox={`0 0 ${FRAME} ${FRAME}`}
            fill="none"
            aria-hidden
            style={{ position: "absolute", inset: 0, transform: "rotate(-90deg)" }}
          >
            {/* Saving draws the ring once around the frame — the crop is
                committed, and the frame itself says so. */}
            <motion.circle
              cx={FRAME / 2}
              cy={FRAME / 2}
              r={FRAME / 2 - 1}
              stroke={accent}
              strokeWidth="2.5"
              strokeLinecap="round"
              initial={{ pathLength: 0 }}
              animate={{ pathLength: saved ? 1 : 0 }}
              transition={
                reduceMotion ? { duration: 0 } : { duration: 0.5, ease: "easeOut" }
              }
            />
          </svg>

          <AnimatePresence>
            {saved && (
              <motion.span
                key="tick"
                aria-hidden
                initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0 }}
                transition={
                  reduceMotion
                    ? { duration: 0.14 }
                    : { type: "spring", stiffness: 420, damping: 36, delay: 0.28 }
                }
                style={{
                  position: "absolute",
                  right: 2,
                  bottom: 2,
                  display: "grid",
                  placeItems: "center",
                  width: 26,
                  height: 26,
                  borderRadius: 999,
                  background: accent,
                  color: "#FFFFFF",
                  border: "2px solid Canvas",
                }}
              >
                <svg width="13" height="13" viewBox="0 0 20 20" fill="none">
                  <motion.path
                    d="M5.5 10.4l3 3 6-6.4"
                    stroke="currentColor"
                    strokeWidth="2.2"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    initial={{ pathLength: reduceMotion ? 1 : 0 }}
                    animate={{ pathLength: 1 }}
                    transition={{ duration: reduceMotion ? 0 : 0.24, delay: 0.34 }}
                  />
                </svg>
              </motion.span>
            )}
          </AnimatePresence>
        </div>

        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{ fontSize: 13, fontWeight: 650 }}>{personName}</div>
          <div style={{ fontSize: 11.5, opacity: 0.5, marginTop: 2 }}>
            {saved ? "Profile photo set" : picked ? "Adjust, then save" : "No photo yet"}
          </div>

          <button
            type="button"
            onClick={() => setPicked(true)}
            disabled={picked}
            style={{
              width: "100%",
              marginTop: 10,
              padding: "9px 12px",
              fontSize: 12.5,
              fontWeight: 600,
              fontFamily: "inherit",
              borderRadius: 10,
              border: `1px solid ${tone(14)}`,
              background: tone(8),
              color: "inherit",
              opacity: picked ? 0.45 : 1,
              cursor: picked ? "default" : "pointer",
            }}
          >
            Choose image
          </button>

          <div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 8 }}>
            <ZoomButton label="Zoom out" onClick={() => nudge(-1)} disabled={!picked || saved}>
              <path d="M6 10h8" />
            </ZoomButton>
            <div
              style={{
                flex: 1,
                height: 4,
                borderRadius: 999,
                background: tone(10),
                overflow: "hidden",
              }}
            >
              <motion.div
                animate={{ scaleX: picked ? (zoom + 1) / 3 + 0.1 : 0 }}
                transition={reduceMotion ? { duration: 0 } : cfg.zoom}
                style={{
                  height: "100%",
                  borderRadius: 999,
                  background: accent,
                  transformOrigin: "left center",
                }}
              />
            </div>
            <ZoomButton label="Zoom in" onClick={() => nudge(1)} disabled={!picked || saved}>
              <path d="M10 6v8M6 10h8" />
            </ZoomButton>
          </div>
        </div>
      </div>

      <button
        type="button"
        onClick={save}
        disabled={!picked || saved}
        style={{
          width: "100%",
          marginTop: 16,
          padding: "11px 14px",
          fontSize: 13,
          fontWeight: 650,
          fontFamily: "inherit",
          borderRadius: 11,
          border: "none",
          background: accent,
          color: "#FFFFFF",
          opacity: !picked || saved ? 0.45 : 1,
          cursor: !picked || saved ? "default" : "pointer",
        }}
      >
        {saved ? "Saved" : "Save photo"}
      </button>
    </div>
  );
}

function ZoomButton({
  label,
  onClick,
  disabled,
  children,
}: {
  label: string;
  onClick: () => void;
  disabled?: boolean;
  children: ReactNode;
}) {
  return (
    <button
      type="button"
      aria-label={label}
      onClick={onClick}
      disabled={disabled}
      style={{
        display: "grid",
        placeItems: "center",
        width: 26,
        height: 26,
        flexShrink: 0,
        borderRadius: 8,
        border: `1px solid ${tone(14)}`,
        background: "transparent",
        color: "inherit",
        opacity: disabled ? 0.35 : 1,
        cursor: disabled ? "default" : "pointer",
      }}
    >
      <svg
        width="14"
        height="14"
        viewBox="0 0 20 20"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.8"
        strokeLinecap="round"
        aria-hidden
      >
        {children}
      </svg>
    </button>
  );
}

About this pattern

The detail that makes a crop feel adjustable rather than final is where the image comes to rest: slightly larger than the frame, with room in every direction. It drops in from above and settles on a spring damped enough that it never rubber-bands against the edge of the mask, while the dashed guide stays put above it so the frame reads as fixed and the picture as movable. Zoom presses move the same scale value on a second spring, so adjusting is the same motion as arriving. Saving draws the ring once around the frame and drops a check into the corner — the crop is committed, and the frame itself says so. The picked file is stood in for by a flat placeholder tile; the component ships with no asset and shows no photograph of a person.

Profile photo setup stepAvatar crop and zoomAccount picture changeFirst-run profile completion

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Set up your workspaceStep 2 of 4
    What should we call it?
    Ridgeline
    Who else is joining?
    3 invited
    Next
    Onboarding flow

    A circular crop with zoom, adjusted in place before it is committed.

Related patterns