All components

Interactive Image Selector

Interactive Image Selector is a React and Tailwind CSS form component from SmoothUI, licensed MIT. Copy it and paste it into your project.

SmoothUI MIT forms

What it is

Interactive Image Selector is a React and Tailwind CSS form component from SmoothUI, licensed MIT. Copy it and paste it into your project.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Five free copies a month. No card.

Needs

npm i motion lucide-react

Licence

This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.

Interactive Image Selector

index.tsx
"use client";

import { Share2, Trash2 } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useState } from "react";

const RESET_DELAY = 200;
const RESET_SCALE_START = 1;
const RESET_SCALE_PEAK = 1.1;
const RESET_SCALE_END = 1;
const RESET_ROTATE_START = 0;
const RESET_ROTATE_POSITIVE = 5;
const RESET_ROTATE_NEGATIVE = -5;
const SELECT_SCALE_START = 1;
const SELECT_SCALE_PEAK = 1.1;
const SELECT_SCALE_END = 1;
const SELECT_ROTATE_START = 0;
const SELECT_ROTATE_NEGATIVE = -5;
const SELECT_ROTATE_POSITIVE = 5;
const CONTAINER_SCALE_START = 1;
const CONTAINER_SCALE_MIN = 0.95;
const CONTAINER_SCALE_END = 1;
const ITEM_SCALE_START = 1;
const ITEM_SCALE_MIN = 0.9;
const ITEM_SCALE_END = 1;
const ITEM_ROTATE_START = 0;
const ITEM_ROTATE_POSITIVE = 2;
const ITEM_ROTATE_NEGATIVE = -2;
const RESET_ANIMATION_DURATION = 0.3;

export interface ImageData {
  id: number;
  src: string;
}

export interface InteractiveImageSelectorProps {
  className?: string;
  images: ImageData[];
  onChange?: (selected: number[]) => void;
  onDelete?: (deleted: number[]) => void;
  onShare?: (selected: number[]) => void;
  selectable?: boolean;
  selectedImages?: number[];
}

export default function InteractiveImageSelector({
  images,
  selectedImages: controlledSelected,
  onChange,
  onDelete,
  onShare,
  className = "",
  selectable = false,
}: InteractiveImageSelectorProps) {
  const [originalImages] = useState<ImageData[]>(images);
  const [internalImages, setInternalImages] = useState<ImageData[]>(images);
  const [internalSelected, setInternalSelected] = useState<number[]>([]);
  const [isSelecting, setIsSelecting] = useState(selectable);
  const [isResetting, setIsResetting] = useState(false);
  const shouldReduceMotion = useReducedMotion();

  const selected = controlledSelected ?? internalSelected;

  const handleImageClick = useCallback(
    (id: number) => {
      if (!isSelecting) {
        return;
      }
      const newSelected = selected.includes(id)
        ? selected.filter((imgId) => imgId !== id)
        : [...selected, id];
      if (onChange) {
        onChange(newSelected);
      } else {
        setInternalSelected(newSelected);
      }
    },
    [isSelecting, selected, onChange]
  );

  const handleDelete = useCallback(() => {
    const newImages = internalImages.filter(
      (img) => !selected.includes(img.id)
    );
    if (onDelete) {
      onDelete(selected);
    }
    setInternalImages(newImages);
    if (onChange) {
      onChange([]);
    } else {
      setInternalSelected([]);
    }
  }, [selected, internalImages, onDelete, onChange]);

  const handleReset = useCallback(() => {
    setIsResetting(true);

    // Add a small delay to show the reset animation
    setTimeout(() => {
      setInternalImages(originalImages);
      if (onChange) {
        onChange([]);
      } else {
        setInternalSelected([]);
      }
      setIsSelecting(false);
      setIsResetting(false);
    }, RESET_DELAY);
  }, [originalImages, onChange]);

  const toggleSelecting = useCallback(() => {
    setIsSelecting((prev) => !prev);
    if (isSelecting) {
      if (onChange) {
        onChange([]);
      } else {
        setInternalSelected([]);
      }
    }
  }, [isSelecting, onChange]);

  const handleShare = useCallback(() => {
    if (onShare) {
      onShare(selected);
    }
  }, [onShare, selected]);

  return (
    <div
      className={`relative flex h-full w-full max-w-[500px] flex-col justify-between p-4 ${className}`}
    >
      <div className="pointer-events-none absolute inset-x-0 top-0 z-10 h-28 bg-linear-to-b from-primary/80 to-transparent dark:from-background/50" />
      <div className="absolute top-5 right-5 left-5 z-20 flex justify-between p-4">
        <motion.button
          animate={
            shouldReduceMotion || !isResetting
              ? {}
              : {
                  rotate: [
                    RESET_ROTATE_START,
                    RESET_ROTATE_POSITIVE,
                    RESET_ROTATE_NEGATIVE,
                    RESET_ROTATE_START,
                  ],
                  scale: [RESET_SCALE_START, RESET_SCALE_PEAK, RESET_SCALE_END],
                }
          }
          aria-label="Reset selection"
          className={`cursor-pointer rounded-full px-3 py-1 font-semibold text-sm bg-blend-luminosity backdrop-blur-xl transition-colors ${
            isResetting
              ? "bg-brand/30 text-white"
              : "bg-background/20 text-foreground"
          }`}
          disabled={isResetting}
          exit={shouldReduceMotion ? {} : { rotate: 0 }}
          initial={shouldReduceMotion ? {} : { rotate: 0 }}
          onClick={handleReset}
          transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.25 }}
          whileHover={shouldReduceMotion ? {} : { scale: 1.05 }}
          whileTap={shouldReduceMotion ? {} : { scale: 0.95 }}
        >
          {isResetting ? "Resetting..." : "Reset"}
        </motion.button>
        <motion.button
          animate={
            isSelecting
              ? {
                  rotate: [
                    SELECT_ROTATE_START,
                    SELECT_ROTATE_NEGATIVE,
                    SELECT_ROTATE_POSITIVE,
                    SELECT_ROTATE_START,
                  ],
                  scale: [
                    SELECT_SCALE_START,
                    SELECT_SCALE_PEAK,
                    SELECT_SCALE_END,
                  ],
                }
              : {}
          }
          aria-label={isSelecting ? "Cancel selection" : "Select images"}
          className={`cursor-pointer rounded-full px-3 py-1 font-semibold text-sm bg-blend-luminosity backdrop-blur-xl ${
            isSelecting
              ? "bg-brand/30 text-white"
              : "bg-background/20 text-foreground"
          }`}
          exit={{ rotate: 0 }}
          initial={{ rotate: 0 }}
          onClick={toggleSelecting}
          transition={{ duration: 0.3 }}
          type="button"
          whileHover={{ scale: 1.05 }}
          whileTap={{ scale: 0.95 }}
        >
          {isSelecting ? "Cancel" : "Select"}
        </motion.button>
      </div>

      <motion.div
        animate={
          shouldReduceMotion || !isResetting
            ? {}
            : {
                scale: [
                  CONTAINER_SCALE_START,
                  CONTAINER_SCALE_MIN,
                  CONTAINER_SCALE_END,
                ],
              }
        }
        className="grid grid-cols-3 gap-1 overflow-scroll"
        layout={!shouldReduceMotion}
        transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.2 }}
      >
        <AnimatePresence>
          {internalImages.map((img) => (
            <motion.div
              animate={{
                opacity: 1,
                rotate: isResetting
                  ? [
                      ITEM_ROTATE_START,
                      ITEM_ROTATE_POSITIVE,
                      ITEM_ROTATE_NEGATIVE,
                      ITEM_ROTATE_START,
                    ]
                  : 0,
                scale: isResetting
                  ? [ITEM_SCALE_START, ITEM_SCALE_MIN, ITEM_SCALE_END]
                  : 1,
              }}
              className="relative aspect-square cursor-pointer"
              exit={{ opacity: 0, scale: 0.8 }}
              initial={{ opacity: 0, scale: 0.8 }}
              key={img.id}
              layout
              onClick={() => handleImageClick(img.id)}
              transition={{
                damping: 25,
                duration: isResetting ? RESET_ANIMATION_DURATION : undefined,
                stiffness: 300,
                type: "spring" as const,
              }}
            >
              <img
                alt={`Gallery item ${img.id}`}
                className={`h-full w-full rounded-lg object-cover ${
                  selected.includes(img.id) && isSelecting ? "opacity-75" : ""
                }`}
                draggable={false}
                height={200}
                loading="lazy"
                src={img.src}
                width={200}
              />
              {isSelecting && selected.includes(img.id) && (
                <div className="absolute right-2 bottom-2 flex h-6 w-6 items-center justify-center rounded-full border border-white bg-brand text-white">
                  ✓
                </div>
              )}
            </motion.div>
          ))}
        </AnimatePresence>
      </motion.div>
      <AnimatePresence>
        {isSelecting ? (
          <motion.div
            animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
            className="absolute right-2 bottom-0 left-1/2 z-10 flex w-2/3 -translate-x-1/2 items-center justify-between rounded-full bg-background/20 p-4 bg-blend-luminosity backdrop-blur-md"
            exit={
              shouldReduceMotion
                ? { opacity: 0, transition: { duration: 0 } }
                : { opacity: 0, y: 20 }
            }
            initial={
              shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: 20 }
            }
            transition={
              shouldReduceMotion ? { duration: 0 } : { duration: 0.25 }
            }
          >
            <button
              className="cursor-pointer text-brand"
              onClick={handleShare}
              type="button"
            >
              <Share2 size={24} />
            </button>
            <span className="text-foreground">{selected.length} selected</span>
            <button
              className="cursor-pointer text-brand"
              disabled={selected.length === 0}
              onClick={handleDelete}
              type="button"
            >
              <Trash2 size={24} />
            </button>
          </motion.div>
        ) : null}
      </AnimatePresence>
    </div>
  );
}

Similar components