# shadcn

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

Cropper blocks for shadcn/ui. Each one installs with the shadcn CLI and becomes code in your project.

## Before you start

The blocks use the colours, `Button` and `Dialog` of your shadcn/ui project, so they follow your theme in light and dark. They work with the Radix and the Base UI styles, and need Tailwind CSS v4 and React 19. The React Aria styles have a different `Dialog` and `Button`, so the blocks do not fit them.

Each command adds the files to your project and installs `@zriz/react` and `@zriz/core`. Every block returns a `File`, ready for a form or an upload.

## Cropper

The zriz parts in shadcn styles, and the base of every block below. It has the viewport with handles and a grid, a zoom slider, the straighten dial, rotate, flip, reset and an aspect ratio switch.

The page at https://zriz.dev/docs/shadcn runs this block: a square cropper with corner handles, a grid and a zoom slider in shadcn styles.

```sh
npx shadcn@latest add https://zriz.dev/r/cropper.json
```

Put the parts you need inside `Cropper`. `cropToFile` turns the crop into a `File`:

```tsx
"use client";

import type { Cropper as CropperApi } from "@zriz/react";
import { useRef } from "react";

import { Button } from "@/components/ui/button";
import {
  Cropper,
  CropperViewport,
  CropperZoom,
  CropperZoomIn,
  CropperZoomOut,
  cropToFile,
} from "@/components/ui/cropper";

type Props = { photo: File; onCrop: (file: File) => void };

export const PhotoCropper = ({ photo, onCrop }: Props) => {
  const actions = useRef<CropperApi>(null);

  const crop = async () => {
    if (actions.current) {
      onCrop(
        await cropToFile(actions.current, {
          type: "image/webp",
        })
      );
    }
  };

  return (
    <Cropper actionsRef={actions} src={photo}>
      <CropperViewport />
      <div className="flex items-center gap-1">
        <CropperZoomOut />
        <CropperZoom />
        <CropperZoomIn />
      </div>
      <Button onClick={crop}>Crop</Button>
    </Cropper>
  );
};
```

## Avatar cropper

A profile photo control. The visitor uploads a photo, moves it under a circle in a dialog and saves a round PNG. Adjust opens the same photo again with the last crop.

The page at https://zriz.dev/docs/shadcn runs this block: an avatar with an upload button that opens a dialog with a round crop and a zoom slider.

```sh
npx shadcn@latest add https://zriz.dev/r/avatar-cropper.json
```

```tsx
"use client";

import { AvatarCropper } from "@/components/avatar-cropper";

const upload = async (file: File) => {
  const body = new FormData();

  body.append("avatar", file);
  await fetch("/api/avatar", { method: "POST", body });
};

export const ProfilePhoto = () => (
  <AvatarCropper onCrop={upload} />
);
```

## Image crop dialog

A dialog you open with any image. It has an aspect ratio switch and a rotate button, and it hands the crop back as a file. Pass your own list of ratios, or one ratio to lock it.

The page at https://zriz.dev/docs/shadcn runs this block: a dialog with a cropper, an aspect ratio switch and a rotate button that returns a file.

```sh
npx shadcn@latest add https://zriz.dev/r/image-crop-dialog.json
```

```tsx
"use client";

import { useState } from "react";

import { ImageCropDialog } from "@/components/image-crop-dialog";

type Props = { onCrop: (file: File) => void };

export const ProductPhoto = ({ onCrop }: Props) => {
  const [photo, setPhoto] = useState<File | null>(null);

  return (
    <>
      <input
        aria-label="Product photo"
        type="file"
        accept="image/*"
        onChange={(event) =>
          setPhoto(event.target.files?.[0] ?? null)
        }
      />
      <ImageCropDialog
        image={photo}
        open={photo !== null}
        onOpenChange={(open) => {
          if (!open) setPhoto(null);
        }}
        aspects={[
          { label: "1:1", value: 1 },
          { label: "4:3", value: 4 / 3 },
        ]}
        exportOptions={{ type: "image/webp", quality: 0.9 }}
        onCrop={onCrop}
      />
    </>
  );
};
```

## Cover cropper

A wide cover image for a profile or a post. Reposition lets the visitor drag and zoom the image in place, and save exports a JPEG at the width you set.

The page at https://zriz.dev/docs/shadcn runs this block: a wide cover image that the visitor drags into place, with change, reposition, save and cancel buttons.

```sh
npx shadcn@latest add https://zriz.dev/r/cover-cropper.json
```

```tsx
"use client";

import { CoverCropper } from "@/components/cover-cropper";

type Props = { cover: string; onCrop: (file: File) => void };

export const ProfileCover = ({ cover, onCrop }: Props) => (
  <CoverCropper
    defaultImage={cover}
    aspect={3}
    width={1500}
    onCrop={onCrop}
  />
);
```

## Image editor

A full editor in a card: the straighten dial, rotate, flip, five aspect ratios and reset. `onSave` gets the file and the crop value, so you can store the value and open the editor on it later. [Saving state](https://zriz.dev/docs/saving-state.md) shows how.

The page at https://zriz.dev/docs/shadcn runs this block: a card with a cropper, a straighten dial, rotate, flip, aspect ratios and reset.

```sh
npx shadcn@latest add https://zriz.dev/r/image-editor.json
```

```tsx
"use client";

import type { CropValue } from "@zriz/react";

import { ImageEditor } from "@/components/image-editor";

type Props = {
  photo: File;
  saved?: CropValue;
  onSave: (file: File, value: CropValue) => void;
};

export const PhotoEditor = ({
  photo,
  saved,
  onSave,
}: Props) => (
  <ImageEditor
    image={photo}
    {...(saved ? { defaultValue: saved } : null)}
    exportOptions={{ type: "image/jpeg", quality: 0.9 }}
    onSave={onSave}
  />
);
```

## Image crop field

A form field. The visitor picks or drops an image and frames it in place. The field keeps a file input in sync with the crop, so a plain form or a server action receives the cropped JPEG under `name`.

The page at https://zriz.dev/docs/shadcn runs this block: a form field that crops the chosen image and submits the crop as a file.

```sh
npx shadcn@latest add https://zriz.dev/r/image-crop-field.json
```

```tsx
import { ImageCropField } from "@/components/image-crop-field";

type Props = { action: (data: FormData) => Promise<void> };

export const PostForm = ({ action }: Props) => (
  <form action={action}>
    <ImageCropField
      name="cover"
      label="Cover image"
      aspect={16 / 9}
    />
    <button type="submit">Publish</button>
  </form>
);
```
