← Dashboard
Zubeyr··Pagesmediaeditor

Image Edit

A product image editor: light and colour adjustments, stackable filter presets, crop with aspect ratios, zoom, straighten, rotate, flip and drag to reposition, hold-to-compare, undo/redo and a floating Cancel and Save toolbar.

Live demo ↗

Installation

Run the shadcn CLI in a project that already has components.json. Using the @tomei tab? Register the namespace once first.

pnpm dlx shadcn@latest add https://tomei.zubeyr.dev/ui/r/image-edit.json
  • →Installs lucide-react from npm.
  • →Pulls in sidebar.
  • →Pulls in button.
  • →Pulls in use-history.
  • →Pulls in toast.
  • →Files land in components/dashboard/image-edit.

Installing by hand? Add the dependencies, then copy the source code below.

terminal.sh
pnpm add lucide-react

Usage

example.tsx
import { ImageEdit } from '@/components/dashboard/image-edit/image-edit'

export default function Demo() {
  return <ImageEdit />
}

Source code

image-edit.tsx
'use client'

import { useRef, useState } from 'react'
import {
  BarChart3,
  Eye,
  Home,
  ImageIcon,
  Package,
  Redo2,
  Settings,
  ShoppingBag,
  Undo2,
  Users,
  X,
} from 'lucide-react'

import { Button } from '@/registry/dashboard/components/button/button'
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarHeader,
  SidebarItem,
} from '@/registry/dashboard/components/sidebar/sidebar'
import { toast, Toaster } from '@/registry/dashboard/components/toast/toast'
import { useHistory } from '@/registry/dashboard/components/use-history/use-history'
import { cn } from '@/lib/utils'

import { defaultEdit, type Edit, type StageImage } from './edits'
import { Panel, type Tab } from './panel'
import { Stage } from './stage'

type Doc = { images: StageImage[]; edits: Record<string, Edit> }

const initial: Doc = {
  images: [
    { id: 'front', name: 'Front', background: '#ece4d8', fill: '#c2410c' },
    { id: 'back', name: 'Back', background: '#dfe5e2', fill: '#3f6212' },
    { id: 'detail', name: 'Detail', background: '#e7e0ef', fill: '#44403c', scale: 2.2 },
  ],
  edits: {
    back: { ...defaultEdit, filter: 'warm', adjust: { ...defaultEdit.adjust, contrast: 12 } },
  },
}

export function ImageEdit() {
  const history = useHistory(initial, { hotkeys: true })
  const doc = history.value
  const [saved, setSaved] = useState(initial)
  const [active, setActive] = useState(0)
  const [tab, setTab] = useState<Tab>('adjust')
  const [comparing, setComparing] = useState(false)
  const [justSaved, setJustSaved] = useState(false)
  const gesture = useRef(0)

  const index = Math.min(active, Math.max(0, doc.images.length - 1))
  const image = doc.images[index]
  const edit = (image && doc.edits[image.id]) ?? defaultEdit
  const dirty = JSON.stringify(doc) !== JSON.stringify(saved)

  const setEdit = (next: Edit, merge?: string) => {
    if (!image) return
    history.set(
      (d) => ({ ...d, edits: { ...d.edits, [image.id]: next } }),
      merge ? { merge: `${image.id}-${merge}-${gesture.current}` } : undefined,
    )
  }

  const setImages = (images: StageImage[]) => {
    history.set((d) => ({
      images,
      edits: Object.fromEntries(
        Object.entries(d.edits).filter(([id]) => images.some((i) => i.id === id)),
      ),
    }))
  }

  const cancel = () => {
    history.reset(saved)
    setActive(0)
  }

  const save = () => {
    setSaved(doc)
    history.reset(doc)
    setJustSaved(true)
    setTimeout(() => setJustSaved(false), 1500)
    toast.success('Images saved', {
      description: `${doc.images.length} image${doc.images.length === 1 ? '' : 's'} updated.`,
    })
  }

  return (
    <div className="flex h-svh overflow-hidden bg-sidebar text-foreground">
      <Sidebar className="border-r-0 max-lg:hidden">
        <SidebarHeader
          logo={<span className="size-8 rounded-lg bg-sidebar-primary-foreground" />}
          title="Tomei"
          subtitle="Store admin"
        />
        <SidebarContent>
          <SidebarGroup>
            <SidebarItem icon={<Home />}>Home</SidebarItem>
            <SidebarItem icon={<ShoppingBag />} count={4}>
              Orders
            </SidebarItem>
            <SidebarItem
              icon={<Package />}
              active
              submenu={
                <>
                  <SidebarItem>Collections</SidebarItem>
                  <SidebarItem>Inventory</SidebarItem>
                  <SidebarItem active>Media</SidebarItem>
                  <SidebarItem>Gift cards</SidebarItem>
                </>
              }
            >
              Products
            </SidebarItem>
            <SidebarItem icon={<Users />}>Customers</SidebarItem>
            <SidebarItem icon={<BarChart3 />}>Analytics</SidebarItem>
          </SidebarGroup>
        </SidebarContent>
        <SidebarFooter>
          <SidebarItem icon={<Settings />}>Settings</SidebarItem>
        </SidebarFooter>
      </Sidebar>

      <main className="min-w-0 flex-1 overflow-y-auto bg-card lg:my-2 lg:mr-2 lg:rounded-2xl lg:border">
        <header className="sticky top-0 z-20 grid grid-cols-[1fr_auto_1fr] items-center gap-3 bg-card/90 px-4 py-3 backdrop-blur">
          <div className="flex min-w-0 items-center gap-2 text-sm">
            <ImageIcon className="size-4 shrink-0 text-muted-foreground" />
            <span className="truncate font-medium">Wool Overshirt</span>
            {image && (
              <span className="truncate text-muted-foreground max-sm:hidden">/ {image.name}</span>
            )}
            {dirty && (
              <span className="text-amber-600 max-md:hidden dark:text-amber-400">
                · Unsaved changes
              </span>
            )}
          </div>

          <div
            role="toolbar"
            aria-label="Edit actions"
            className="flex items-center gap-1 rounded-xl bg-primary p-1 text-primary-foreground shadow-lg"
          >
            <button
              type="button"
              disabled={!dirty}
              onClick={cancel}
              className="h-7 rounded-lg px-3 text-sm font-medium hover:bg-primary-foreground/10 disabled:opacity-40 disabled:hover:bg-transparent"
            >
              Cancel
            </button>
            <button
              type="button"
              disabled={!dirty}
              onClick={save}
              className={cn(
                'h-7 rounded-lg bg-primary-foreground px-3 text-sm font-medium text-primary disabled:opacity-50',
                justSaved && 'disabled:opacity-100',
              )}
            >
              {justSaved ? 'Saved ✓' : 'Save'}
            </button>
          </div>

          <div className="flex justify-end gap-1.5">
            <Button
              size="icon"
              variant="ghost"
              aria-label="Undo"
              title="Undo (Ctrl+Z)"
              disabled={!history.canUndo}
              onClick={history.undo}
            >
              <Undo2 />
            </Button>
            <Button
              size="icon"
              variant="ghost"
              aria-label="Redo"
              title="Redo (Ctrl+Shift+Z)"
              disabled={!history.canRedo}
              onClick={history.redo}
            >
              <Redo2 />
            </Button>
            <Button
              size="icon"
              aria-label="Hold to compare with original"
              title="Hold to compare"
              aria-pressed={comparing}
              disabled={!image}
              onPointerDown={() => setComparing(true)}
              onPointerUp={() => setComparing(false)}
              onPointerLeave={() => setComparing(false)}
              onKeyDown={(e) => e.key === ' ' && setComparing(true)}
              onKeyUp={() => setComparing(false)}
            >
              <Eye />
            </Button>
            <Button size="icon" variant="ghost" aria-label="Close">
              <X />
            </Button>
          </div>
        </header>

        <div className="mx-auto grid max-w-[1200px] gap-10 px-6 pt-6 pb-16 lg:grid-cols-[minmax(0,1fr)_20rem]">
          <div
            className="flex items-start justify-center rounded-3xl py-6"
            style={{
              background:
                'radial-gradient(circle at 50% 40%, color-mix(in oklab, var(--chart-5) 10%, transparent), transparent 65%)',
            }}
          >
            <Stage
              images={doc.images}
              edits={doc.edits}
              active={index}
              cropping={tab === 'crop'}
              comparing={comparing}
              onSelect={setActive}
              onImages={setImages}
              onPan={(x, y, start) => {
                if (start) gesture.current++
                else setEdit({ ...edit, crop: { ...edit.crop, x, y } }, 'pan')
              }}
            />
          </div>

          <Panel
            tab={tab}
            image={image}
            edit={edit}
            onTab={setTab}
            onEdit={setEdit}
            onStart={() => gesture.current++}
          />
        </div>
      </main>
      <Toaster />
    </div>
  )
}
edits.ts
export type Adjust = {
  exposure: number
  contrast: number
  saturation: number
  warmth: number
  hue: number
  fade: number
  vignette: number
}

export type Crop = {
  aspect: string
  zoom: number
  x: number
  y: number
  straighten: number
  turns: number
  flipX: boolean
  flipY: boolean
}

export type Edit = { filter: string; adjust: Adjust; crop: Crop }

export type StageImage = {
  id: string
  name: string
  src?: string
  background?: string
  fill?: string
  scale?: number
}

export const adjustments: { key: keyof Adjust; label: string; min: number; max: number }[] = [
  { key: 'exposure', label: 'Exposure', min: -100, max: 100 },
  { key: 'contrast', label: 'Contrast', min: -100, max: 100 },
  { key: 'saturation', label: 'Saturation', min: -100, max: 100 },
  { key: 'warmth', label: 'Warmth', min: -100, max: 100 },
  { key: 'hue', label: 'Hue', min: -180, max: 180 },
  { key: 'fade', label: 'Fade', min: 0, max: 100 },
  { key: 'vignette', label: 'Vignette', min: 0, max: 100 },
]

export const aspects = [
  { id: 'original', label: 'Original', ratio: 4 / 5 },
  { id: 'square', label: '1:1', ratio: 1 },
  { id: 'portrait', label: '3:4', ratio: 3 / 4 },
  { id: 'story', label: '9:16', ratio: 9 / 16 },
  { id: 'wide', label: '16:9', ratio: 16 / 9 },
]

export const filters: { id: string; label: string; adjust: Partial<Adjust> }[] = [
  { id: 'none', label: 'None', adjust: {} },
  { id: 'vivid', label: 'Vivid', adjust: { saturation: 35, contrast: 15 } },
  { id: 'warm', label: 'Warm', adjust: { warmth: 45, exposure: 5 } },
  { id: 'cool', label: 'Cool', adjust: { warmth: -45, saturation: -10 } },
  { id: 'fade', label: 'Fade', adjust: { fade: 45, contrast: -15 } },
  { id: 'mono', label: 'Mono', adjust: { saturation: -100, contrast: 20 } },
  {
    id: 'noir',
    label: 'Noir',
    adjust: { saturation: -100, contrast: 45, exposure: -15, vignette: 50 },
  },
]

export const defaultEdit: Edit = {
  filter: 'none',
  adjust: { exposure: 0, contrast: 0, saturation: 0, warmth: 0, hue: 0, fade: 0, vignette: 0 },
  crop: {
    aspect: 'original',
    zoom: 1,
    x: 0,
    y: 0,
    straighten: 0,
    turns: 0,
    flipX: false,
    flipY: false,
  },
}

const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value))

export function combined(edit: Edit): Adjust {
  const preset = filters.find((f) => f.id === edit.filter)?.adjust ?? {}
  const result = { ...edit.adjust }
  for (const { key, min, max } of adjustments) {
    result[key] = clamp(edit.adjust[key] + (preset[key] ?? 0), min, max)
  }
  return result
}

export function ratioOf(edit: Edit) {
  const ratio = aspects.find((a) => a.id === edit.crop.aspect)?.ratio ?? 4 / 5
  return edit.crop.turns % 2 && edit.crop.aspect === 'original' ? 1 / ratio : ratio
}

export function coverScale(edit: Edit) {
  const r = ratioOf(edit)
  const angle = ((edit.crop.turns * 90 + edit.crop.straighten) * Math.PI) / 180
  const cos = Math.abs(Math.cos(angle))
  const sin = Math.abs(Math.sin(angle))
  return Math.max(cos + sin / r, sin * r + cos)
}

export function panLimit(edit: Edit) {
  return ((edit.crop.zoom * coverScale(edit) - 1) / 2) * 100
}
panel.tsx
'use client'

import {
  Crop,
  FlipHorizontal2,
  FlipVertical2,
  RotateCcw,
  RotateCw,
  Sparkles,
  SunMedium,
} from 'lucide-react'

import { Button } from '@/registry/dashboard/components/button/button'
import { cn } from '@/lib/utils'

import { adjustments, aspects, defaultEdit, filters, type Edit, type StageImage } from './edits'
import { Picture } from './picture'
import { Slider } from './slider'

export type Tab = 'adjust' | 'filters' | 'crop'

const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
  { id: 'adjust', label: 'Adjust', icon: <SunMedium /> },
  { id: 'filters', label: 'Filters', icon: <Sparkles /> },
  { id: 'crop', label: 'Crop', icon: <Crop /> },
]

type PanelProps = {
  tab: Tab
  image?: StageImage
  edit: Edit
  onTab: (tab: Tab) => void
  onEdit: (next: Edit, merge?: string) => void
  onStart: () => void
}

function Section({ title, children }: { title: string; children: React.ReactNode }) {
  return (
    <section className="flex flex-col gap-4">
      <h3 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">{title}</h3>
      {children}
    </section>
  )
}

export function Panel({ tab, image, edit, onTab, onEdit, onStart }: PanelProps) {
  const edited = JSON.stringify(edit) !== JSON.stringify(defaultEdit)
  const crop = (patch: Partial<Edit['crop']>, merge?: string) =>
    onEdit({ ...edit, crop: { ...edit.crop, ...patch } }, merge)

  return (
    <aside className="flex flex-col gap-6 lg:sticky lg:top-20 lg:self-start">
      <div
        role="tablist"
        aria-label="Edit tools"
        className="grid grid-cols-3 gap-1 rounded-xl bg-muted p-1"
      >
        {tabs.map((t) => (
          <button
            key={t.id}
            type="button"
            role="tab"
            aria-selected={tab === t.id}
            onClick={() => onTab(t.id)}
            className={cn(
              'flex h-8 items-center justify-center gap-1.5 rounded-lg text-sm font-medium transition-colors [&_svg]:size-3.5',
              tab === t.id
                ? 'bg-card text-foreground shadow-(--shadow-button)'
                : 'text-muted-foreground hover:text-foreground',
            )}
          >
            {t.icon}
            {t.label}
          </button>
        ))}
      </div>

      {tab === 'adjust' && (
        <Section title="Light & colour">
          {adjustments.map((a) => (
            <Slider
              key={a.key}
              label={a.label}
              min={a.min}
              max={a.max}
              value={edit.adjust[a.key]}
              onStart={onStart}
              onChange={(value) =>
                onEdit({ ...edit, adjust: { ...edit.adjust, [a.key]: value } }, a.key)
              }
            />
          ))}
        </Section>
      )}

      {tab === 'filters' && (
        <Section title="Presets">
          <div className="grid grid-cols-3 gap-3">
            {filters.map((f) => (
              <button
                key={f.id}
                type="button"
                aria-pressed={edit.filter === f.id}
                onClick={() => onEdit({ ...edit, filter: f.id })}
                className="group flex flex-col gap-1.5 text-left"
              >
                {image ? (
                  <Picture
                    image={image}
                    edit={{ ...edit, filter: f.id }}
                    crop={false}
                    className={cn(
                      'aspect-square w-full rounded-lg ring-offset-2 ring-offset-card transition',
                      edit.filter === f.id ? 'ring-2 ring-foreground' : 'group-hover:opacity-90',
                    )}
                  />
                ) : (
                  <div className="aspect-square w-full rounded-lg bg-muted" />
                )}
                <span
                  className={cn(
                    'text-xs',
                    edit.filter === f.id ? 'font-medium text-foreground' : 'text-muted-foreground',
                  )}
                >
                  {f.label}
                </span>
              </button>
            ))}
          </div>
          <p className="text-xs text-muted-foreground">Presets stack on top of your adjustments.</p>
        </Section>
      )}

      {tab === 'crop' && (
        <>
          <Section title="Aspect ratio">
            <div className="flex flex-wrap gap-1.5">
              {aspects.map((a) => (
                <button
                  key={a.id}
                  type="button"
                  aria-pressed={edit.crop.aspect === a.id}
                  onClick={() => crop({ aspect: a.id, x: 0, y: 0 })}
                  className={cn(
                    'h-7 rounded-full border px-3 text-xs font-medium transition-colors',
                    edit.crop.aspect === a.id
                      ? 'border-foreground bg-foreground text-background'
                      : 'text-muted-foreground hover:border-ring hover:text-foreground',
                  )}
                >
                  {a.label}
                </button>
              ))}
            </div>
          </Section>
          <Section title="Transform">
            <Slider
              label="Zoom"
              min={1}
              max={3}
              step={0.05}
              origin={1}
              format={(v) => `${v.toFixed(2)}×`}
              value={edit.crop.zoom}
              onStart={onStart}
              onChange={(zoom) => crop({ zoom, x: 0, y: 0 }, 'zoom')}
            />
            <Slider
              label="Straighten"
              min={-45}
              max={45}
              format={(v) => `${v}°`}
              value={edit.crop.straighten}
              onStart={onStart}
              onChange={(straighten) => crop({ straighten, x: 0, y: 0 }, 'straighten')}
            />
            <div className="grid grid-cols-4 gap-1.5">
              <Button
                size="icon"
                className="w-full"
                aria-label="Rotate left"
                onClick={() => crop({ turns: (edit.crop.turns + 3) % 4, x: 0, y: 0 })}
              >
                <RotateCcw />
              </Button>
              <Button
                size="icon"
                className="w-full"
                aria-label="Rotate right"
                onClick={() => crop({ turns: (edit.crop.turns + 1) % 4, x: 0, y: 0 })}
              >
                <RotateCw />
              </Button>
              <Button
                size="icon"
                className="w-full"
                aria-label="Flip horizontal"
                aria-pressed={edit.crop.flipX}
                onClick={() => crop({ flipX: !edit.crop.flipX })}
              >
                <FlipHorizontal2 />
              </Button>
              <Button
                size="icon"
                className="w-full"
                aria-label="Flip vertical"
                aria-pressed={edit.crop.flipY}
                onClick={() => crop({ flipY: !edit.crop.flipY })}
              >
                <FlipVertical2 />
              </Button>
            </div>
            <p className="text-xs text-muted-foreground">Drag the image to reposition it.</p>
          </Section>
        </>
      )}

      <div className="border-t pt-4">
        <Button
          variant="ghost"
          className="w-full"
          disabled={!edited}
          onClick={() => onEdit(defaultEdit)}
        >
          Reset all edits
        </Button>
      </div>
    </aside>
  )
}
picture.tsx
import { cn } from '@/lib/utils'

import { combined, coverScale, type Edit, type StageImage } from './edits'

function Art({ image }: { image: StageImage }) {
  if (image.src) {
    return <img src={image.src} alt="" draggable={false} className="size-full object-cover" />
  }
  return (
    <div className="size-full" style={{ background: image.background }}>
      <svg
        viewBox="0 0 200 250"
        className="size-full"
        style={{ transform: `scale(${image.scale ?? 1})` }}
        aria-hidden
      >
        <ellipse cx="100" cy="228" rx="70" ry="8" fill="rgb(0 0 0 / 0.12)" />
        <path
          d="M72 34 L100 44 L128 34 L166 56 L182 112 L160 120 L154 98 L154 222 L46 222 L46 98 L40 120 L18 112 L34 56 Z"
          fill={image.fill}
        />
        <path d="M72 34 L100 64 L128 34" fill="none" stroke="rgb(0 0 0 / 0.25)" strokeWidth="3" />
        <path d="M100 64 V222" stroke="rgb(0 0 0 / 0.2)" strokeWidth="2" />
        {[84, 112, 140, 168, 196].map((y) => (
          <circle key={y} cx="104" cy={y} r="2.5" fill="rgb(255 255 255 / 0.6)" />
        ))}
        <rect x="60" y="92" width="26" height="28" rx="3" fill="rgb(0 0 0 / 0.12)" />
        <rect x="114" y="92" width="26" height="28" rx="3" fill="rgb(0 0 0 / 0.12)" />
      </svg>
    </div>
  )
}

export function Picture({
  image,
  edit,
  crop = true,
  className,
}: {
  image: StageImage
  edit: Edit
  crop?: boolean
  className?: string
}) {
  const a = combined(edit)
  const { zoom, x, y, straighten, turns, flipX, flipY } = edit.crop
  const scale = crop ? zoom * coverScale(edit) : 1
  const transform = crop
    ? `translate(${x}%, ${y}%) rotate(${turns * 90 + straighten}deg) scale(${scale * (flipX ? -1 : 1)}, ${scale * (flipY ? -1 : 1)})`
    : undefined
  const filter = [
    `brightness(${1 + a.exposure / 200})`,
    `contrast(${1 + a.contrast / 100 - a.fade / 400})`,
    `saturate(${1 + a.saturation / 100})`,
    `hue-rotate(${a.hue}deg)`,
  ].join(' ')

  return (
    <div className={cn('relative overflow-hidden', className)}>
      <div className="absolute inset-0" style={{ transform, filter }}>
        <Art image={image} />
      </div>
      {a.warmth !== 0 && (
        <div
          className="absolute inset-0 mix-blend-soft-light"
          style={{
            background: a.warmth > 0 ? '#ff8a00' : '#0066ff',
            opacity: (Math.abs(a.warmth) / 100) * 0.6,
          }}
        />
      )}
      {a.fade > 0 && (
        <div className="absolute inset-0 bg-white" style={{ opacity: (a.fade / 100) * 0.3 }} />
      )}
      {a.vignette > 0 && (
        <div
          className="absolute inset-0"
          style={{
            background: `radial-gradient(ellipse at center, transparent 45%, rgb(0 0 0 / ${(a.vignette / 100) * 0.75}))`,
          }}
        />
      )}
    </div>
  )
}
slider.tsx
'use client'

import { useRef } from 'react'

import { cn } from '@/lib/utils'

type SliderProps = {
  label: string
  value: number
  min: number
  max: number
  step?: number
  origin?: number
  format?: (value: number) => string
  onStart?: () => void
  onChange: (value: number) => void
}

export function Slider({
  label,
  value,
  min,
  max,
  step = 1,
  origin = Math.max(min, Math.min(max, 0)),
  format = (v) => (v > 0 && min < 0 ? `+${v}` : `${v}`),
  onStart,
  onChange,
}: SliderProps) {
  const track = useRef<HTMLDivElement>(null)
  const pct = (v: number) => ((v - min) / (max - min)) * 100
  const snap = (v: number) => {
    const next = Math.round(Math.min(max, Math.max(min, v)) / step) * step
    return Number(next.toFixed(4))
  }

  const fromPointer = (clientX: number) => {
    const rect = track.current!.getBoundingClientRect()
    onChange(snap(min + ((clientX - rect.left) / rect.width) * (max - min)))
  }

  const from = Math.min(pct(value), pct(origin))
  const to = Math.max(pct(value), pct(origin))

  return (
    <div className="flex flex-col gap-2">
      <div className="flex items-center justify-between text-sm">
        <span className="text-muted-foreground">{label}</span>
        <button
          type="button"
          onClick={() => {
            onStart?.()
            onChange(origin)
          }}
          title="Reset"
          className={cn(
            'rounded px-1 font-mono text-xs tabular-nums',
            value === origin ? 'text-muted-foreground' : 'text-foreground hover:bg-muted',
          )}
        >
          {format(value)}
        </button>
      </div>
      <div
        ref={track}
        role="slider"
        tabIndex={0}
        aria-label={label}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuenow={value}
        aria-valuetext={format(value)}
        onPointerDown={(e) => {
          e.currentTarget.setPointerCapture(e.pointerId)
          onStart?.()
          fromPointer(e.clientX)
        }}
        onPointerMove={(e) => {
          if (e.currentTarget.hasPointerCapture(e.pointerId)) fromPointer(e.clientX)
        }}
        onDoubleClick={() => {
          onStart?.()
          onChange(origin)
        }}
        onKeyDown={(e) => {
          const big = (max - min) / 10
          const moves: Record<string, number> = {
            ArrowLeft: -step,
            ArrowDown: -step,
            ArrowRight: step,
            ArrowUp: step,
            PageDown: -big,
            PageUp: big,
          }
          let next: number
          if (e.key in moves) next = snap(value + moves[e.key] * (e.shiftKey ? 10 : 1))
          else if (e.key === 'Home') next = min
          else if (e.key === 'End') next = max
          else return
          e.preventDefault()
          if (!e.repeat) onStart?.()
          onChange(next)
        }}
        className="group relative flex h-5 cursor-pointer touch-none items-center rounded-full outline-none"
      >
        <div className="absolute inset-x-0 h-1 rounded-full bg-muted" />
        <div
          className="absolute h-1 rounded-full bg-foreground"
          style={{ left: `${from}%`, width: `${to - from}%` }}
        />
        {min < origin && (
          <div
            className="absolute h-2.5 w-px -translate-x-1/2 bg-muted-foreground/50"
            style={{ left: `${pct(origin)}%` }}
          />
        )}
        <div
          className="absolute size-4 -translate-x-1/2 rounded-full border bg-card shadow-sm transition-transform group-focus-visible:ring-2 group-focus-visible:ring-ring/60 group-active:scale-110"
          style={{ left: `${pct(value)}%` }}
        />
      </div>
    </div>
  )
}
stage.tsx
'use client'

import { useRef, useState } from 'react'
import { ImagePlus, Plus, Trash2, Upload } from 'lucide-react'

import { cn } from '@/lib/utils'

import { defaultEdit, panLimit, ratioOf, type Edit, type StageImage } from './edits'
import { Picture } from './picture'

type StageProps = {
  images: StageImage[]
  edits: Record<string, Edit>
  active: number
  cropping: boolean
  comparing: boolean
  onSelect: (index: number) => void
  onImages: (images: StageImage[]) => void
  onPan: (x: number, y: number, start: boolean) => void
}

const fromFile = (file: File): StageImage => ({
  id: `${file.name}-${file.lastModified}-${Math.random().toString(36).slice(2, 7)}`,
  name: file.name.replace(/\.[^.]+$/, ''),
  src: URL.createObjectURL(file),
})

export function Stage({
  images,
  edits,
  active,
  cropping,
  comparing,
  onSelect,
  onImages,
  onPan,
}: StageProps) {
  const replaceInput = useRef<HTMLInputElement>(null)
  const addInput = useRef<HTMLInputElement>(null)
  const drag = useRef<{ px: number; py: number; x: number; y: number } | null>(null)
  const [dragging, setDragging] = useState(false)
  const current = images[active]
  const edit = (current && edits[current.id]) ?? defaultEdit
  const shown = comparing ? defaultEdit : edit
  const ratio = ratioOf(shown)

  const replace = (file?: File) => {
    if (!file?.type.startsWith('image/')) return
    if (!current) return onImages([fromFile(file)])
    onImages(images.map((image, i) => (i === active ? fromFile(file) : image)))
  }

  const add = (files: FileList | null) => {
    const added = [...(files ?? [])].filter((f) => f.type.startsWith('image/')).map(fromFile)
    if (!added.length) return
    onImages([...images, ...added])
    onSelect(images.length)
  }

  const remove = () => {
    onImages(images.filter((_, i) => i !== active))
    onSelect(Math.max(0, active - 1))
  }

  return (
    <div className="flex w-full flex-col items-center gap-5">
      <div
        onDragOver={(e) => {
          e.preventDefault()
          setDragging(true)
        }}
        onDragLeave={() => setDragging(false)}
        onDrop={(e) => {
          e.preventDefault()
          setDragging(false)
          replace(e.dataTransfer.files[0])
        }}
        className={cn(
          'group relative overflow-hidden rounded-2xl bg-muted shadow-(--shadow-card) transition-[width,aspect-ratio] duration-300 motion-reduce:transition-none',
          dragging && 'ring-2 ring-ring ring-offset-4',
        )}
        style={{ aspectRatio: ratio, width: `min(100%, calc(min(64svh, 600px) * ${ratio}))` }}
      >
        {current ? (
          <div
            className={cn('size-full', cropping && 'cursor-grab touch-none active:cursor-grabbing')}
            onPointerDown={(e) => {
              if (!cropping) return
              e.currentTarget.setPointerCapture(e.pointerId)
              drag.current = { px: e.clientX, py: e.clientY, x: edit.crop.x, y: edit.crop.y }
              onPan(edit.crop.x, edit.crop.y, true)
            }}
            onPointerMove={(e) => {
              if (!drag.current) return
              const rect = e.currentTarget.getBoundingClientRect()
              const limit = panLimit(edit)
              const clamp = (v: number) =>
                Math.round(Math.max(-limit, Math.min(limit, v)) * 10) / 10
              onPan(
                clamp(drag.current.x + ((e.clientX - drag.current.px) / rect.width) * 100),
                clamp(drag.current.y + ((e.clientY - drag.current.py) / rect.height) * 100),
                false,
              )
            }}
            onPointerUp={() => (drag.current = null)}
            onPointerCancel={() => (drag.current = null)}
          >
            <Picture image={current} edit={shown} className="size-full" />
          </div>
        ) : (
          <button
            type="button"
            onClick={() => replaceInput.current?.click()}
            className="flex size-full flex-col items-center justify-center gap-2 text-sm text-muted-foreground"
          >
            <ImagePlus className="size-6" />
            Drop an image or click to upload
          </button>
        )}

        {cropping && current && (
          <div className="pointer-events-none absolute inset-0 grid grid-cols-3 grid-rows-3 border-2 border-white/80">
            {Array.from({ length: 9 }, (_, i) => (
              <div key={i} className="border-[0.5px] border-white/40" />
            ))}
          </div>
        )}

        {comparing && (
          <span className="absolute top-3 left-3 rounded-md bg-black/60 px-2 py-1 text-xs font-medium text-white">
            Original
          </span>
        )}

        {current && !cropping && (
          <div className="absolute inset-x-0 bottom-0 flex justify-center gap-2 bg-gradient-to-t from-black/50 to-transparent p-4 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
            <button
              type="button"
              onClick={() => replaceInput.current?.click()}
              className="flex h-8 items-center gap-1.5 rounded-lg bg-white px-3 text-sm font-medium text-neutral-900"
            >
              <Upload className="size-3.5" /> Replace
            </button>
            <button
              type="button"
              onClick={remove}
              className="flex h-8 items-center gap-1.5 rounded-lg bg-white/90 px-3 text-sm font-medium text-red-600"
            >
              <Trash2 className="size-3.5" /> Remove
            </button>
          </div>
        )}

        {dragging && (
          <div className="absolute inset-0 flex items-center justify-center bg-background/70 text-sm font-medium">
            Drop to replace
          </div>
        )}
      </div>

      <div className="flex flex-wrap justify-center gap-2">
        {images.map((image, i) => (
          <button
            key={image.id}
            type="button"
            aria-label={`Edit ${image.name}`}
            aria-pressed={i === active}
            onClick={() => onSelect(i)}
            className={cn(
              'size-14 overflow-hidden rounded-lg ring-offset-2 ring-offset-card transition',
              i === active ? 'ring-2 ring-foreground' : 'opacity-70 hover:opacity-100',
            )}
          >
            <Picture image={image} edit={edits[image.id] ?? defaultEdit} className="size-full" />
          </button>
        ))}
        <button
          type="button"
          aria-label="Add images"
          onClick={() => addInput.current?.click()}
          className="flex size-14 items-center justify-center rounded-lg border border-dashed text-muted-foreground hover:border-ring hover:text-foreground"
        >
          <Plus className="size-4" />
        </button>
      </div>

      <input
        ref={replaceInput}
        type="file"
        accept="image/*"
        hidden
        onChange={(e) => {
          replace(e.target.files?.[0])
          e.target.value = ''
        }}
      />
      <input
        ref={addInput}
        type="file"
        accept="image/*"
        multiple
        hidden
        onChange={(e) => {
          add(e.target.files)
          e.target.value = ''
        }}
      />
    </div>
  )
}