Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/react/src/components/swap/use-swap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { RenderStrategyProps } from 'src/utils/render-strategy'
import type { RenderStrategyProps } from '../../utils/render-strategy'
import type { HTMLProps } from '../factory'
import { type UsePresenceReturn, usePresence } from '../presence/use-presence'
import { parts } from './swap.anatomy'
Expand Down
4 changes: 2 additions & 2 deletions website/src/components/marketing/annoucement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ export const Announcement = () => {
}

return (
<NextLink href="/blog/we-improved-the-docs">
<NextLink href="/docs/components/image-cropper">
<Badge size="lg" variant="outline">
<Icon color="colorPalette.default">
<SparklesIcon />
</Icon>
DX: We improved the docs
[New] Image Cropper component
<ArrowRightIcon />
</Badge>
</NextLink>
Expand Down
139 changes: 139 additions & 0 deletions website/src/content/pages/components/image-cropper.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
---
id: image-cropper
title: Image Cropper
description: Crop and transform images with zoom, rotation, and aspect ratio controls.
status: preview
---

<ComponentPreview id="ImageCropper" />

## Anatomy

<Anatomy id="image-cropper" />

```tsx
<ImageCropper.Root>
<ImageCropper.Viewport>
<ImageCropper.Image />
<ImageCropper.Selection>
<ImageCropper.Handle />
<ImageCropper.Grid />
</ImageCropper.Selection>
</ImageCropper.Viewport>
</ImageCropper.Root>
```

## Examples

### Basic

Set up a basic image cropper. Drag the handles to resize the selection, or drag inside to pan the image.

<Example id="basic" />

### Aspect Ratio

Lock the crop area to a specific aspect ratio. Use the `aspectRatio` prop—pass a number like `16/9` for widescreen or
`1` for square.

<Example id="aspect-ratio" />

### Circle Crop

Use `cropShape="circle"` for profile pictures or avatars. The selection becomes a circle instead of a rectangle.

<Example id="circle" />

### Initial Crop

Start with a pre-defined crop area using the `initialCrop` prop. Pass an object with `x`, `y`, `width`, and `height` in
pixels.

<Example id="initial-crop" />

### Controlled Zoom

Control zoom programmatically with the `zoom` and `onZoomChange` props. Useful when you want external buttons to zoom in
and out.

<Example id="controlled-zoom" />

### Zoom Limits

Set `minZoom` and `maxZoom` to constrain how far users can zoom. Prevents over-zooming or zooming out past the image
bounds.

<Example id="zoom-limits" />

### Rotation

Rotate the image with the `rotation` and `onRotationChange` props. Values are in degrees—common increments are 90
or 180.

<Example id="rotation" />

### Flip

Flip the image horizontally or vertically using the `flip` prop. Pass an object with `horizontal` and `vertical`
booleans.

<Example id="flip" />

### Min and Max Size

Constrain the crop area size with `minWidth`, `minHeight`, `maxWidth`, and `maxHeight`. Keeps the selection within
sensible bounds.

<Example id="min-max-size" />

### Fixed Crop Area

Set `fixedCropArea` to `true` when the crop area should stay fixed while the image moves underneath. Useful for
overlay-style cropping.

<Example id="fixed" />

### Crop Preview

Use `getCroppedImage()` from the context to get the cropped result. Call it with `{ output: 'dataUrl' }` for a base64
string you can use in an `img` src.

<Example id="crop-preview" />

### Reset

The context exposes a `reset()` method that restores the image to its initial state. Handy for an "undo" or "start over"
button.

<Example id="reset" />

### Events

Listen to `onCropChange` and `onZoomChange` to track crop position and zoom level. Use these to sync with external state
or show live previews.

<Example id="events" />

### Context

Use `ImageCropper.Context` to access the cropper API from anywhere inside the root. You get methods like `zoomBy`,
`rotateBy`, and `setZoom`.

<Example id="context" />

### Root Provider

Use `RootProvider` with `useImageCropper` when you need to control the cropper from outside the component tree. Build
custom toolbars or integrate with form state.

<Example id="root-provider" />

## API Reference

### Props

<ComponentTypes id="image-cropper" />

### Context

<ContextType id="image-cropper" />
181 changes: 181 additions & 0 deletions website/src/demos/image-cropper.demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
'use client'

import { ImageCropper, imageCropperAnatomy } from '@ark-ui/react/image-cropper'
import { sva } from 'styled-system/css'

const styles = sva({
slots: imageCropperAnatomy.keys(),
className: 'image-cropper',
base: {
root: {
'--cropper-accent': '{colors.colorPalette.9}',
'--cropper-line-color': 'white',
'--cropper-overlay-color': 'rgb(0 0 0 / 0.5)',
'--cropper-handler-size': '10px',
'--cropper-handler-width': '4px',
'--cropper-line-width': '2px',
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
width: '100%',
maxWidth: '28rem',
color: 'fg.default',
height: '20rem',
},
viewport: {
position: 'relative',
overflow: 'hidden',
bg: 'bg.subtle',
aspectRatio: '1',
},
image: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'contain',
transformOrigin: 'center center',
pointerEvents: 'none',
userSelect: 'none',
backfaceVisibility: 'hidden',
},
selection: {
boxSizing: 'content-box',
boxShadow: '0 0 0 9999px var(--cropper-overlay-color)',
border: 'var(--cropper-line-width) solid rgb(255 255 255 / 0.5)',
cursor: 'move',
outline: 'none',
backfaceVisibility: 'hidden',
'&[data-shape="circle"]': {
borderRadius: 'full',
},
_focusVisible: {
borderColor: 'var(--cropper-accent)',
},
'&[data-disabled]': {
cursor: 'default',
},
'&[data-dragging]': {
cursor: 'grabbing',
borderColor: 'rgb(255 255 255 / 0.8)',
},
},
handle: {
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
touchAction: 'none',
width: 'calc(var(--cropper-handler-size) + 8px)',
height: 'calc(var(--cropper-handler-size) + 8px)',
'& > *': {
width: 'var(--cropper-handler-size)',
height: 'var(--cropper-handler-size)',
bg: 'white',
boxShadow: '0 2px 6px rgb(0 0 0 / 0.4)',
transition: 'opacity 0.2s ease, transform 0.15s ease',
},
'&[data-disabled]': {
display: 'none',
},
'&[data-position="top-left"], &[data-position="top-right"], &[data-position="bottom-right"], &[data-position="bottom-left"]':
{
'&:hover > *': {
transform: 'scale(1.1)',
},
},
'&[data-position="top-left"]': {
cursor: 'nwse-resize',
'& > *': {
borderLeft: 'var(--cropper-handler-width) solid var(--cropper-accent)',
borderTop: 'var(--cropper-handler-width) solid var(--cropper-accent)',
},
},
'&[data-position="top-right"]': {
cursor: 'nesw-resize',
'& > *': {
borderRight: 'var(--cropper-handler-width) solid var(--cropper-accent)',
borderTop: 'var(--cropper-handler-width) solid var(--cropper-accent)',
},
},
'&[data-position="bottom-right"]': {
cursor: 'nwse-resize',
'& > *': {
borderRight: 'var(--cropper-handler-width) solid var(--cropper-accent)',
borderBottom: 'var(--cropper-handler-width) solid var(--cropper-accent)',
},
},
'&[data-position="bottom-left"]': {
cursor: 'nesw-resize',
'& > *': {
borderLeft: 'var(--cropper-handler-width) solid var(--cropper-accent)',
borderBottom: 'var(--cropper-handler-width) solid var(--cropper-accent)',
},
},
'&[data-position="top"], &[data-position="bottom"], &[data-position="left"], &[data-position="right"]': {
'& > *': {
width: '10px',
height: '10px',
bg: 'var(--cropper-accent)',
borderRadius: 'full',
boxShadow: '0 0 0 2px white',
opacity: 1,
},
'&:hover > *': {
transform: 'scale(1.2)',
},
},
'&[data-position="top"], &[data-position="bottom"]': {
cursor: 'ns-resize',
},
'&[data-position="left"], &[data-position="right"]': {
cursor: 'ew-resize',
},
},
grid: {
position: 'absolute',
pointerEvents: 'none',
opacity: 0,
transition: 'opacity 0.2s ease',
'&[data-axis="horizontal"]': {
inset: '33.33% 0',
borderTop: '1px solid rgb(255 255 255 / 0.4)',
borderBottom: '1px solid rgb(255 255 255 / 0.4)',
},
'&[data-axis="vertical"]': {
inset: '0 33.33%',
borderLeft: '1px solid rgb(255 255 255 / 0.4)',
borderRight: '1px solid rgb(255 255 255 / 0.4)',
},
'&[data-dragging], &[data-panning]': {
opacity: 1,
},
},
},
})

export const Demo = (props: ImageCropper.RootProps) => {
const classNames = styles()
return (
<ImageCropper.Root {...props} className={classNames.root} defaultZoom={1.5}>
<ImageCropper.Viewport className={classNames.viewport}>
<ImageCropper.Image
className={classNames.image}
src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800"
alt="Sample"
/>
<ImageCropper.Selection className={classNames.selection}>
{ImageCropper.handles.map((position) => (
<ImageCropper.Handle className={classNames.handle} key={position} position={position}>
<div />
</ImageCropper.Handle>
))}
<ImageCropper.Grid className={classNames.grid} axis="horizontal" />
<ImageCropper.Grid className={classNames.grid} axis="vertical" />
</ImageCropper.Selection>
</ImageCropper.Viewport>
</ImageCropper.Root>
)
}
1 change: 1 addition & 0 deletions website/src/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export { Demo as FormatRelativeTime } from './format-relative-time.demo'
export { Demo as Frame } from './frame.demo'
export { Demo as Highlight } from './highlight.demo'
export { Demo as HoverCard } from './hover-card.demo'
export { Demo as ImageCropper } from './image-cropper.demo'
export { Demo as JsonTreeView } from './json-tree-view.demo'
export { Demo as Listbox } from './listbox.demo'
export { Demo as Menu } from './menu.demo'
Expand Down
8 changes: 8 additions & 0 deletions website/src/lib/example-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,10 @@ import * as Steps_Basic from '@examples/steps/examples/basic'
import * as Steps_Controlled from '@examples/steps/examples/controlled'
import * as Steps_RootProvider from '@examples/steps/examples/root-provider'
import * as Steps_Vertical from '@examples/steps/examples/vertical'
import * as Swap_Fade from '@examples/swap/examples/fade'
import * as Swap_Flip from '@examples/swap/examples/flip'
import * as Swap_Rotate from '@examples/swap/examples/rotate'
import * as Swap_Scale from '@examples/swap/examples/scale'
import * as Switch_Basic from '@examples/switch/examples/basic'
import * as Switch_Context from '@examples/switch/examples/context'
import * as Switch_Controlled from '@examples/switch/examples/controlled'
Expand Down Expand Up @@ -997,6 +1001,10 @@ const exampleModules: Record<string, ExampleModule> = {
'steps/controlled': Steps_Controlled,
'steps/root-provider': Steps_RootProvider,
'steps/vertical': Steps_Vertical,
'swap/fade': Swap_Fade,
'swap/flip': Swap_Flip,
'swap/rotate': Swap_Rotate,
'swap/scale': Swap_Scale,
'switch/basic': Switch_Basic,
'switch/context': Switch_Context,
'switch/controlled': Switch_Controlled,
Expand Down
1 change: 1 addition & 0 deletions website/src/lib/sidebar-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const sidebarConfig: SidebarGroupConfig[] = [
{ id: 'fieldset' },
{ id: 'file-upload' },
{ id: 'floating-panel' },
{ id: 'image-cropper' },
{ id: 'hover-card' },
{ id: 'listbox' },
{ id: 'marquee' },
Expand Down