HextaUI

BetaFeito sobre o UI SDK 0.1.0-beta.1 da Vercel. A API pode mudar.

Image cropper

Um recortador de imagens sobre o UI SDK da Vercel com seleção redimensionável, proporções, moldura redonda para avatar, zoom, rotação e espelhamento.

  • Componente
  • 10 partes
  • 6 exemplos
Depende de
  • @tabler/icons-react
  • @ui-sdk/core
  • @ui-sdk/react
  • class-variance-authority
A rowing boat on a green mountain lake
"use client"

import * as React from "react"
import { cropImage } from "@ui-sdk/react/image"

import { Button } from "@/components/ui/button"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import {
  ImageCropper,
  ImageCropperGrid,
  ImageCropperHandles,
  ImageCropperImage,
  ImageCropperSelection,
  ImageCropperViewport,
  type ImageCrop,
} from "@/components/ui/ui-sdk-image-cropper"

const photo =
  "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200&q=80&auto=format&fit=crop"

const ratios = [
  { label: "Free", value: "free", ratio: undefined },
  { label: "Square", value: "square", ratio: 1 },
  { label: "Wide", value: "wide", ratio: 16 / 9 },
]

function UiSdkImageCropperDemo() {
  const [ratio, setRatio] = React.useState("wide")
  const [crop, setCrop] = React.useState<ImageCrop>()
  const [result, setResult] = React.useState<string>()

  React.useEffect(
    () => () => {
      if (result) URL.revokeObjectURL(result)
    },
    [result]
  )

  async function save() {
    if (!crop) return
    const file = await cropImage(photo, crop, { width: 480 })
    setResult(URL.createObjectURL(file))
  }

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <ToggleGroup
        aria-label="Aspect ratio"
        value={[ratio]}
        onValueChange={(next) => {
          if (next[0]) setRatio(next[0])
        }}
        variant="outline"
        size="sm"
      >
        {ratios.map((item) => (
          <ToggleGroupItem key={item.value} value={item.value}>
            {item.label}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
      <ImageCropper
        aria-label="Photo"
        src={photo}
        aspectRatio={ratios.find((item) => item.value === ratio)?.ratio}
        minWidth={80}
        minHeight={80}
        crop={crop}
        onCropChange={setCrop}
      >
        <ImageCropperViewport>
          <ImageCropperImage alt="A rowing boat on a green mountain lake" />
          <ImageCropperSelection>
            <ImageCropperGrid />
            <ImageCropperHandles />
          </ImageCropperSelection>
        </ImageCropperViewport>
      </ImageCropper>
      <div className="flex items-center gap-3">
        <Button size="sm" onClick={save} disabled={!crop}>
          Crop
        </Button>
        {crop && (
          <span className="text-sm text-muted-foreground tabular-nums">
            {Math.round(crop.width)} × {Math.round(crop.height)}
          </span>
        )}
      </div>
      {result && (
        <img
          src={result}
          alt="The cropped photo"
          className="w-full rounded-lg"
        />
      )}
    </div>
  )
}

export { UiSdkImageCropperDemo }
pnpm dlx shadcn@latest add https://hextaui.com/r/ui-sdk-image-cropper.json

Adiciona o componente, os tokens de tema do HextaUI e quaisquer componentes do HextaUI dos quais ele depende.

O UI SDK adiciona um pouco de CSS próprio em uma camada de cascata chamada ui-sdk. Declare essa camada no topo da sua folha de estilos global, acima do import do Tailwind, para que suas classes vençam.

@layer ui-sdk;

@import "tailwindcss";
import {
  ImageCropper,
  ImageCropperGrid,
  ImageCropperHandles,
  ImageCropperImage,
  ImageCropperSelection,
  ImageCropperViewport,
} from "@/components/ui/ui-sdk-image-cropper"
<ImageCropper aria-label="Cover photo" src={src} aspectRatio={3}>
  <ImageCropperViewport>
    <ImageCropperImage alt="Your cover photo" />
    <ImageCropperSelection>
      <ImageCropperGrid />
      <ImageCropperHandles />
    </ImageCropperSelection>
  </ImageCropperViewport>
</ImageCropper>

O recorte é um retângulo nos pixels da própria imagem, com rotação e espelhamento. Passe-o para cropImage para obter um arquivo que você pode enviar. Uma imagem de outro site precisa de CORS, ou o navegador não deixa a página ler seus pixels.

import { cropImage } from "@ui-sdk/react/image"

const file = await cropImage(src, crop, { width: 512 })
ImageCropper
├── ImageCropperViewport
│   ├── ImageCropperImage
│   └── ImageCropperSelection
│       ├── ImageCropperGrid
│       └── ImageCropperHandles
├── ImageCropperZoom
├── ImageCropperRotate
└── ImageCropperFlip

Com mode="frame", a seleção fica parada e você move e amplia a imagem por baixo. shape="circle" a arredonda, então passe também shape: "circle" para cropImage.

"use client"

import * as React from "react"
import { IconUser } from "@tabler/icons-react"
import { cropImage } from "@ui-sdk/react/image"

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  ImageCropper,
  ImageCropperImage,
  ImageCropperRotate,
  ImageCropperSelection,
  ImageCropperViewport,
  ImageCropperZoom,
  type ImageCrop,
} from "@/components/ui/ui-sdk-image-cropper"

const photo =
  "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=1200&q=80&auto=format&fit=crop"

function UiSdkImageCropperAvatar() {
  const [crop, setCrop] = React.useState<ImageCrop>()
  const [avatar, setAvatar] = React.useState<string>()

  React.useEffect(
    () => () => {
      if (avatar) URL.revokeObjectURL(avatar)
    },
    [avatar]
  )

  async function save() {
    if (!crop) return
    const file = await cropImage(photo, crop, { width: 128, shape: "circle" })
    setAvatar(URL.createObjectURL(file))
  }

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <ImageCropper
        aria-label="Profile photo"
        src={photo}
        mode="frame"
        aspectRatio={1}
        shape="circle"
        maxZoom={4}
        crop={crop}
        onCropChange={setCrop}
      >
        <ImageCropperViewport>
          <ImageCropperImage alt="Snowy peaks above a sea of clouds" />
          <ImageCropperSelection />
        </ImageCropperViewport>
        <div className="flex items-center gap-2">
          <ImageCropperZoom />
          <ImageCropperRotate />
        </div>
      </ImageCropper>
      <div className="flex items-center gap-3">
        <Button size="sm" onClick={save} disabled={!crop}>
          Save avatar
        </Button>
        <Avatar>
          {avatar && <AvatarImage src={avatar} alt="Your new avatar" />}
          <AvatarFallback>
            <IconUser />
          </AvatarFallback>
        </Avatar>
      </div>
    </div>
  )
}

export { UiSdkImageCropperAvatar }

ImageCropperRotate gira a imagem um quarto no sentido horário e ImageCropperFlip a espelha. Sem filhos, são botões de ícone com rótulos em inglês.

import {
  ImageCropper,
  ImageCropperFlip,
  ImageCropperGrid,
  ImageCropperHandles,
  ImageCropperImage,
  ImageCropperRotate,
  ImageCropperSelection,
  ImageCropperViewport,
} from "@/components/ui/ui-sdk-image-cropper"

const photo =
  "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200&q=80&auto=format&fit=crop"

function UiSdkImageCropperRotateFlip() {
  return (
    <ImageCropper
      aria-label="Photo"
      src={photo}
      aspectRatio={4 / 5}
      className="max-w-sm"
    >
      <ImageCropperViewport>
        <ImageCropperImage alt="A rowing boat on a green mountain lake" />
        <ImageCropperSelection>
          <ImageCropperGrid />
          <ImageCropperHandles />
        </ImageCropperSelection>
      </ImageCropperViewport>
      <div className="flex gap-2">
        <ImageCropperRotate />
        <ImageCropperFlip />
        <ImageCropperFlip direction="vertical" />
      </div>
    </ImageCropper>
  )
}

export { UiSdkImageCropperRotateFlip }

Recorte uma foto de um input de arquivo com uma URL de objeto. Dê ao recortador uma key para que uma foto nova comece um recorte novo.

"use client"

import * as React from "react"

import { Input } from "@/components/ui/input"
import {
  ImageCropper,
  ImageCropperGrid,
  ImageCropperHandles,
  ImageCropperImage,
  ImageCropperSelection,
  ImageCropperViewport,
} from "@/components/ui/ui-sdk-image-cropper"

function UiSdkImageCropperUpload() {
  const [src, setSrc] = React.useState<string>()

  React.useEffect(
    () => () => {
      if (src) URL.revokeObjectURL(src)
    },
    [src]
  )

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <Input
        type="file"
        accept="image/*"
        aria-label="Choose a photo"
        onChange={(event) => {
          const file = event.target.files?.[0]
          if (file) setSrc(URL.createObjectURL(file))
        }}
      />
      {src ? (
        <ImageCropper key={src} aria-label="Photo" src={src} aspectRatio={1}>
          <ImageCropperViewport>
            <ImageCropperImage alt="The photo you chose" />
            <ImageCropperSelection>
              <ImageCropperGrid />
              <ImageCropperHandles />
            </ImageCropperSelection>
          </ImageCropperViewport>
        </ImageCropper>
      ) : (
        <p className="text-sm text-muted-foreground">
          Choose a photo to crop it.
        </p>
      )}
    </div>
  )
}

export { UiSdkImageCropperUpload }

onCropCommit roda quando um arraste ou tecla termina, então salve ali. Um controlador de createImageCropperController() define o recorte de qualquer lugar.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  createImageCropperController,
  ImageCropper,
  ImageCropperGrid,
  ImageCropperHandles,
  ImageCropperImage,
  ImageCropperSelection,
  ImageCropperViewport,
  type ImageCrop,
} from "@/components/ui/ui-sdk-image-cropper"

const photo =
  "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200&q=80&auto=format&fit=crop"

const start = { x: 120, y: 80, width: 480, height: 480 }

function UiSdkImageCropperControlled() {
  const [cropper] = React.useState(createImageCropperController)
  const [saved, setSaved] = React.useState<ImageCrop | null>(null)

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <ImageCropper
        aria-label="Photo"
        src={photo}
        aspectRatio={1}
        defaultCrop={start}
        controller={cropper}
        onCropCommit={setSaved}
      >
        <ImageCropperViewport>
          <ImageCropperImage alt="A rowing boat on a green mountain lake" />
          <ImageCropperSelection>
            <ImageCropperGrid />
            <ImageCropperHandles />
          </ImageCropperSelection>
        </ImageCropperViewport>
      </ImageCropper>
      <div className="flex items-center gap-3">
        <Button
          variant="outline"
          size="sm"
          onClick={() => cropper.setCrop(start)}
        >
          Reset
        </Button>
        <span className="text-sm text-muted-foreground tabular-nums">
          {saved
            ? `Saved ${Math.round(saved.width)} × ${Math.round(saved.height)} at ${Math.round(saved.x)}, ${Math.round(saved.y)}`
            : "Move the crop to save it."}
        </span>
      </div>
    </div>
  )
}

export { UiSdkImageCropperControlled }

Um recortador desativado não recebe foco nem muda, e seu zoom e botões ficam desativados.

import {
  ImageCropper,
  ImageCropperHandles,
  ImageCropperImage,
  ImageCropperRotate,
  ImageCropperSelection,
  ImageCropperViewport,
  ImageCropperZoom,
} from "@/components/ui/ui-sdk-image-cropper"

const photo =
  "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200&q=80&auto=format&fit=crop"

function UiSdkImageCropperDisabled() {
  return (
    <ImageCropper
      aria-label="Photo"
      src={photo}
      aspectRatio={16 / 9}
      disabled
      className="max-w-md"
    >
      <ImageCropperViewport>
        <ImageCropperImage alt="A rowing boat on a green mountain lake" />
        <ImageCropperSelection>
          <ImageCropperHandles />
        </ImageCropperSelection>
      </ImageCropperViewport>
      <div className="flex items-center gap-2">
        <ImageCropperZoom />
        <ImageCropperRotate />
      </div>
    </ImageCropper>
  )
}

export { UiSdkImageCropperDisabled }
TeclaAção
←→↑↓Move a seleção ou, em uma alça, move suas bordas.
Shift←Move dez vezes mais.
+-Amplia ou reduz a partir do centro do recorte.
RGira a imagem um quarto no sentido horário, ou de volta com Shift.
EscDurante um arraste, devolve o recorte para onde o arraste começou.

O recortador é um grupo nomeado por aria-label. A seleção e cada alça são sliders que dizem o recorte em palavras, como “300 by 300 pixels, at 50, 0”, e o zoom diz seu nível, como “125%”. Esses nomes estão em inglês, então passe aria-label para cada parte no idioma da sua página.

A grade só aparece enquanto você arrasta, e nunca com movimento reduzido. As alças ganham uma área de toque maior em telas de toque, a página não rola durante um arraste, e no modo de cores forçadas a seleção ganha uma borda na cor do sistema.

Cada parte envolve a parte do UI SDK de mesmo nome e aceita todas as suas props, incluindo render e um className que pode ser uma função do estado da parte.

PropTipoPadrão
src
string–
cropO recorte em pixels da imagem, com rotation, flipX e flipY.
ImageCrop–
defaultCrop
ImageCrop–
onCropChange
(crop, details) => void–
onCropCommitRoda quando um arraste, tecla ou roda termina.
(crop, details) => void–
mode
"box" | "frame""box"
aspectRatio
number–
shape
"rectangle" | "circle""rectangle"
minWidth
number–
minHeight
number–
maxZoom
number10
disabled
booleanfalse
controllerDe createImageCropperController().
ImageCropperController–
AtributoDescrição
data-slot="image-cropper"Seleciona o recortador no CSS.
data-modebox ou frame.
data-shaperectangle ou circle.
data-loadingPresente até a imagem carregar.
data-errorPresente quando a imagem falhou ao carregar.
data-draggingPresente enquanto um ponteiro arrasta.
PropTipoPadrão
render
ReactElement | (props, state) => ReactElement–
AtributoDescrição
data-slot="image-cropper-viewport"Seleciona o viewport no CSS.
--image-cropper-aspect-ratioA largura da imagem dividida pela altura depois de carregar. O viewport a usa como proporção.
PropTipoPadrão
altObrigatório.
string–
render
ReactElement | (props, state) => ReactElement–
PropTipoPadrão
aria-label
string"Crop"
render
ReactElement | (props, state) => ReactElement–
AtributoDescrição
data-slot="image-cropper-selection"Seleciona a seleção no CSS.
PropTipoPadrão
childrenPor padrão, linhas em terços.
ReactNode–
PropTipoPadrão
positionA borda ou o canto que redimensiona.
"n" | "e" | "s" | "w" | "ne" | "nw" | "se" | "sw"–

ImageCropperHandles renderiza as oito. As alças não aparecem no modo frame.

Um input range nativo de 1, o maior recorte, até maxZoom.

PropTipoPadrão
childrenPor padrão, um ícone com o rótulo Rotate. Com filhos, é um botão de texto pequeno.
ReactNode–
PropTipoPadrão
direction
"horizontal" | "vertical""horizontal"
childrenPor padrão, um ícone com rótulo em inglês.
ReactNode–