Skip to content
Native Animation
Focal Crop Frame animation preview

This preview uses fictional demo content. See the props below for the customization options available in this component.

npx nativeanimation add focal-crop-frame

Focal Crop Frame

A photo crop editor with animated ratio changes, pan, pinch, and rotation, with configurable image, ratio options, palette, and fonts.

$9 for this animation

Add to your project

Copy the component and its assets into your app with the Native Animation CLI, or paste the agent prompt into Claude Code, Codex or Cursor once the MCP server is connected.

About this animation

A photo crop editor with animated ratio changes, pan, pinch, and rotation, with configurable image, ratio options, palette, and fonts. It defines a source-space output region for your app to export.

How it works

  1. onLayout measures the image stage, reserving 24 points around the frame for full 48-point corner targets.
  2. One UI-thread crop record contains the normalized source center, relative zoom, rotation, and frame dimensions. All image, frame, grid, and scrim geometry comes from that record.
  3. Minimum coverage is max((|cos θ| × frameWidth + |sin θ| × frameHeight) / imageWidth, (|sin θ| × frameWidth + |cos θ| × frameHeight) / imageHeight). Every intermediate animated state is normalized, keeping all selected pixels covered.
  4. Pan applies inverse-rotated deltas; pinch holds the source point under the fingers. Ratio changes keep the source center, except when the source edge requires clamping. Corner drags resize about that center while preserving the selected ratio.
  5. onConfirm inverse-maps TL, TR, BR, BL into source pixels. These are four actual corners, not an axis-aligned bounding box. No image manipulation dependency or file export is included.

Install

npx expo install expo-image expo-blur expo-linear-gradient react-native-gesture-handler react-native-reanimated react-native-worklets

Copy the component folder without demo/. Wrap the host in GestureHandlerRootView and configure Reanimated/Worklets for your Expo SDK. The host provides safe areas once. No theme or catalog imports are required by the component.

Usage

import { FocalCropFrame } from './focal-crop-frame';

<FocalCropFrame
  source={require('./photo.jpg')}
  imageSize={{ width: 3072, height: 3840 }}
  onConfirm={({ transform, sourceCorners }) => {
    // Pass this transform and clockwise corner tuple to your export pipeline.
  }}
/>
const editor = useRef<FocalCropFrameHandle>(null);

<FocalCropFrame
  ref={editor}
  source={require('./photo.jpg')}
  imageSize={{ width: 3072, height: 3840 }}
  defaultCrop={{ centerX: 0.6, centerY: 0.55, scale: 1.3, rotation: 0 }}
  maxZoom={2}
  colorScheme="dark"
  palette={{ accent: '#ACC9EE', onAccent: '#151720' }}
  labels={{ confirm: 'Use frame', help: 'Zoom is limited by source resolution.' }}
  motion={{ durationScale: 1, glowOpacity: 0.25 }}
/>

// Requests are ignored while disabled or loading, except reset while disabled.
editor.current?.setAspectRatio(1);
editor.current?.setCrop({ centerX: 0.6, centerY: 0.55, scale: 1.2, rotation: 6 });
editor.current?.confirm();

Import useRef from React and FocalCropFrameHandle from this folder for the second example.

Props

PropType / defaultPurpose
sourceImageSourcePropType, requiredHost photo, local or remote.
imageSize{ width; height }, requiredActual source dimensions in pixels.
crop / defaultCropCropTransform / centered, zoom 1Controlled value or initial internal value.
aspectRationumber, omittedControlled ratio. Internal selection initially 4:5 when omitted.
onAspectRatioChange(ratio) => voidControlled ratio request.
maxZoomnumber, 2Relative to rotated minimum coverage.
sourceDensitynumber, max(3, PixelRatio.get())Required source pixels per rendered point.
onCropChange(transform) => voidAt most 20 Hz; final value also arrives in commit.
onCropCommit(transform) => voidOne successful action completion; no commit for cancellation.
onConfirm({ transform, sourceCorners }) => voidSource pixels in clockwise TL, TR, BR, BL order.
ratiosRatioOption[], square/portrait/landscapeIDs, ratios, and visible labels.
labelsPartial<FocalCropFrameLabels>All tool, fallback, helper, and accessible copy.
formatValue(transform) => stringRotation/zoom display and accessible value.
formatAnnouncement(transform) => string, omittedOptional single announcement per commit.
onReady / onInteraction() => voidImage loaded / real touch, keyboard, or accessibility action.
disabledboolean, falseBlocks actions, refs, and gestures except reset.
colorSchemelight / dark / auto, autoComplete Paper/Night themes.
palettePartial<Palette>All material colors, scrim, grid, highlight, and focus.
fontFamily / monoFontFamilystring, systemControls and numerical values.
titleFontFamilystring, systemReserved heading integration prop; this editor has no heading.
motion{ durationScale; glowOpacity }Clamped to 0.5–2 and 0–0.3.
styleStyleProp<ViewStyle>Root override, applied last.
testID / accessibilityLabelstringsRoot identity and image adjustment label.

CropTransform = { centerX, centerY, scale, rotation }: centers are normalized source coordinates, scale is relative to minimum rotated coverage, and rotation is in degrees (−15 to +15). Handle: setAspectRatio(ratio), setCrop(transform), reset(), confirm(). Reset restores defaultCrop (or a centered crop) and leaves the current ratio selected.

Controlled props win: imperative actions issue change/commit requests without replacing crop; ratio requests call onAspectRatioChange. Gestures show transient manipulation, then restore the controlled transform until the host accepts it. Frame size is local and is represented by the returned corners rather than added to CropTransform; retain the corners when persisting arbitrary handle resizes.

Nonfinite centers/rotation/scale fall back to 0.5/0/1. Nonpositive dimensions fall back to 1; invalid ratios fall back to 4:5 and ratios are bounded to 1:4–4:1. Duplicate/empty ratio IDs and invalid entries are skipped; an empty list simply omits ratio choices. Zoom is clamped to at least coverage. Unknown ratio IDs are never used for actions. Source errors reserve stage geometry and expose Retry.

Resolution and demo art

Zoom cap is max(1, min(maxZoom, 1 / (minimumCoverage × sourceDensity))). Minimum coverage includes both source dimensions and rotation. When the original is too small even at zoom 1, coverage takes precedence over density; no synthetic upscale asset is created. Provide a helper label explaining the source limit to users.

The demo deliberately uses density 2, rather than the component's default 3, for its original 1024×1280 FLUX photograph (81 KB JPEG). It is copied unchanged from the supplied art, with no enlargement. The cap varies with stage geometry and rotation; it approaches 1× on a large tablet. Supply larger originals to obtain the full requested zoom. Demo artwork belongs only in demo/assets/; reference art is excluded. Orrin uses the kit aperture glyph. Icons: Lucide (ISC).

Compatibility

Targets Expo SDK 58, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, and Gesture Handler 3.3, with the New Architecture. Expo Go includes these installed modules.

The toolbar is translucent over a real gradient on iOS/web. Android deliberately uses a solid palette surface: no unsupported legacy blur target assumptions. Every blur wrapper has its own radius and clipping. There is no Skia canvas or GPU animation loop.

Accessibility

All buttons have 48-point minimum targets and stable IDs. Ratio selection, rotation, zoom, moving the focal center, and frame size have button alternatives. Aspect ratio and rotation occupy separate compact rows; More opens zoom, focal movement, and frame sizing in the same toolbar. Back returns to the primary controls. Localize these buttons with labels.moreTools and labels.mainTools. The image has adjustable screen-reader actions. On web, focus the image and use arrows to move the focal center, +/− for zoom, Enter to confirm, Escape to reset; buttons support Tab/Enter. Pass localized ratio labels and announcements; layout follows the host's RTL direction while source coordinates retain their physical image axes.

Reduced motion changes immediately to a valid covered transform and removes grid/spring travel. It also responds when the system preference changes while mounted. The toolbar has no internal scrolling. The image stage fills the remaining editor height using a zero flex basis, while both tool panels retain 48-point touch targets. Compact button padding keeps the ratio and rotation rows complete in a 300-point host. The primary stage occupies more than 45% of a 390×844 demo viewport. Provide a usable editor height (300×400 is the compact target); the host handles overflow for larger text or shorter layouts. The demo uses the available screen height instead of a fixed editor height and has no scrolling at 320×694 or 390×844. Below 600 points tall, one outer page scroll preserves a 694-point page with the full editor and footer, with its indicator hidden. The brand header reserves space for the viewer close button. No font scaling is disabled. The host handles focus restoration when closing its own modal.

Why this one

This defines a transformed output region instead of inspecting a gallery or presenting editing steps. Rotation-aware coverage, focal continuity, and inverse-mapped source corners keep the result consistent enough for a host export pipeline without tying the UI to a file format.